diff --git a/scripts b/scripts deleted file mode 160000 index 190e596..0000000 --- a/scripts +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 190e59627eaf51f8378ad94619d4fd744ae7adfc diff --git a/scripts/FLAGS.txt b/scripts/FLAGS.txt new file mode 100644 index 0000000..b9ba1fd --- /dev/null +++ b/scripts/FLAGS.txt @@ -0,0 +1,53 @@ +SniffCat: +1 - DNS Compromise: Altering DNS records resulting in improper redirection to malicious IPs. +2 - DNS Poisoning: Falsifying domain server cache (cache poisoning) with fake DNS responses. +3 - DDoS Attack: Participating in distributed denial-of-service attack causing service unavailability. +4 - Port Scan: Scanning TCP/UDP ports to discover open services or vulnerabilities. +5 - Mass Scanner: Automated scanning of multiple hosts or entire network subnets. +6 - Exploited Host: Host compromised and controlled by attacker, actively used for malicious activities. +7 - Malware Hosting: Server distributing malicious software, scripts, or infected files. +8 - C&C Beaconing: Communication with malware command-and-control (C2) infrastructure. +9 - Cryptojacking: Unauthorized use of victim's system resources for cryptocurrency mining. +10 - Phishing: Impersonating legitimate websites to steal credentials or personal data. +11 - Hacking: General hacking attempts to gain unauthorized access or escalate privileges. +12 - SQL Injection: Injecting malicious SQL queries through user input fields. +13 - Command Injection: Executing arbitrary system commands through vulnerable application backend. +14 - Spam Activity: Automated generation of spam (forms, comments, messages, emails). +15 - Bad Web Bot: Malicious bot ignoring robots.txt or impersonating legitimate user-agents. +16 - Path Traversal: Accessing system files outside application directory using ../ techniques. +17 - Brute-Force: Mass login attempts using automated credential guessing. +18 - SSH/SFTP: Unauthorized login attempts to SSH or SFTP services. +19 - FTP: Unauthorized login attempts to FTP server. +20 - Email (SMTP/IMAP/POP3): Unauthorized access attempts to email services. +21 - HTTP/HTTPS: Suspicious HTTP/S requests with malicious headers or payloads. +22 - RDP: Unauthorized login attempts to Remote Desktop Protocol. +23 - Telnet: Unauthorized access to Telnet service, commonly on IoT devices. +24 - SMB: Unauthorized login attempts to SMB/CIFS file shares. +25 - MongoDB: Unauthorized access attempts to MongoDB database instances. +26 - Redis: Unauthorized login attempts to open Redis instances. +27 - Other Abuse: Security-violating activity not covered by other categories. + +AbuseIPDB: +1 - DNS Compromise: Altering DNS records resulting in improper redirection to malicious IPs. +2 - DNS Poisoning: Falsifying domain server cache (cache poisoning) with fake DNS responses. +3 - Fraud Orders: Fraudulent orders placed using stolen payment information or fake identities. +4 - DDoS Attack: Participating in distributed denial-of-service attack, usually as part of a botnet. +5 - FTP Brute-Force: Automated credential guessing attacks against FTP services. +6 - Ping of Death: Sending oversized or malformed ICMP packets to crash or destabilize systems. +7 - Phishing: Impersonating legitimate websites or sending deceptive emails to steal credentials. +8 - Fraud VoIP: Fraudulent activity involving Voice over IP services or toll fraud. +9 - Open Proxy: Operating as open proxy, open relay, or Tor exit node enabling anonymous abuse. +10 - Web Spam: Comment/forum spam, HTTP referer spam, or other CMS-based spam content. +11 - Email Spam: Spam email content, infected attachments, and phishing emails. +12 - Blog Spam: CMS blog comment spam targeting WordPress, Drupal, or similar platforms. +13 - VPN IP: Conjunctive category for traffic originating from VPN or anonymizing services. +14 - Port Scan: Scanning for open ports and vulnerable services across network ranges. +15 - Hacking: General hacking attempts to gain unauthorized access or escalate privileges. +16 - SQL Injection: Injecting malicious SQL queries through user input fields to access databases. +17 - Spoofing: Email sender spoofing or IP address spoofing to impersonate trusted sources. +18 - Brute-Force: Credential brute-force attacks on logins and services like SSH, FTP, SIP, SMTP, RDP. +19 - Bad Web Bot: Malicious bot ignoring robots.txt, scraping content, or spoofing user-agents. +20 - Exploited Host: Host compromised with malware, used for attacks or hosting malicious content. +21 - Web App Attack: Probing or exploiting web applications like WordPress, phpMyAdmin, or e-commerce. +22 - SSH: Unauthorized login attempts or abuse targeting Secure Shell (SSH) services. +23 - IoT Targeted: Abuse targeting Internet of Things devices like routers, cameras, or smart devices. diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..91281ea --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,33 @@ +# ๐Ÿ”ง Integration Scripts + +## Supported Services +- [SniffCat](https://sniffcat.com) +- [AbuseIPDB](https://www.abuseipdb.com/user/158699) + +## Used for +| | [SniffCat](https://sniffcat.com/users/681e779a3e78a7a67816b8a2) | [AbuseIPDB](https://www.abuseipdb.com/user/158699) | +|------------|----------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------| +| UFW | [UFW-SniffCat-Reporter](https://github.com/SniffCatDB/UFW-SniffCat-Reporter) | [UFW-AbuseIPDB-Reporter](https://github.com/sefinek/UFW-AbuseIPDB-Reporter) | +| Cloudflare | [Cloudflare-WAF-To-SniffCat](https://github.com/SniffCatDB/Cloudflare-WAF-To-SniffCat) | [Cloudflare-WAF-To-AbuseIPDB](https://github.com/sefinek/Cloudflare-WAF-To-AbuseIPDB) | +| T-Pot | [T-Pot-To-SniffCat](https://github.com/SniffCatDB/T-Pot-To-SniffCat) | [T-Pot-To-AbuseIPDB](https://github.com/sefinek/T-Pot-To-AbuseIPDB) | +| Suricata | Soon | [Suricata-To-AbuseIPDB](https://github.com/sefinek/Suricata-To-AbuseIPDB) | + +## Terms +| Key | Description | +|-------------|------------------------------------------------------| +| `timestamp` | Event timestamp (UTC format) | +| `srcIp` | Source IP address (attacker) | +| `dstIp` | Destination IP address (victim/server) | +| `proto` | Protocol used (e.g., TCP, SSH, TELNET, MONGOD) | +| `spt` | Source port number | +| `dpt` | Destination port number | +| `in` | Incoming network interface name (e.g., enp1s0, ens3) | +| `out` | Outgoing network interface name | +| `mac` | MAC address information | +| `len` | Total packet length (in bytes) | +| `ttl` | Packet TTL (Time To Live) | +| `id` | Packet ID | +| `tos` | Type of Service field in IP header | +| `prec` | Precedence value in TOS field | +| `window` | TCP window size | +| `urgp` | TCP urgent pointer | diff --git a/scripts/banners/cloudflare.js b/scripts/banners/cloudflare.js new file mode 100644 index 0000000..89b8244 --- /dev/null +++ b/scripts/banners/cloudflare.js @@ -0,0 +1,14 @@ +const { version, authorMeta, prettyName, license } = require('../repo.js'); +const { color, reset, headerText, authorText, donate, sniffcat } = require('./utils/helper.js'); +const logger = require('../logger.js'); + +module.exports = () => { + console.log(` + ${color('255;160;0')} .--. ${headerText(`Cloudflare WAF To ${prettyName} - v${version} `)} + ${color('255;140;0')}.-( ). ${authorText(authorMeta)} [${license}] + ${color('255;120;0')}(___.__)__)${reset} +`); + + logger.info(donate); + logger.info(sniffcat); +}; \ No newline at end of file diff --git a/scripts/banners/suricata.js b/scripts/banners/suricata.js new file mode 100644 index 0000000..e72a291 --- /dev/null +++ b/scripts/banners/suricata.js @@ -0,0 +1,16 @@ +const { version, authorMeta, prettyName, repoUrl, license } = require('../repo.js'); +const { color, reset, headerText, authorText, donate } = require('./utils/helper.js'); +const logger = require('../logger.js'); + +module.exports = () => { + console.log(` + ${color('255;140;0')} /\\_/\\ + ${color('255;120;0')} ( o.o ) ${headerText(`Suricata To ${prettyName} - v${version}`)} + ${color('255;100;0')} > ^ < ${authorText(authorMeta)} + ${color('255;80;0')} / \\ ${authorText(`${repoUrl} [${license}]`)} + ${color('255;70;0')}( ) + ${color('255;40;0')} \\__|__/${reset} +`); + + logger.info(donate); +}; diff --git a/scripts/banners/t-pot.js b/scripts/banners/t-pot.js new file mode 100644 index 0000000..1c8e2bb --- /dev/null +++ b/scripts/banners/t-pot.js @@ -0,0 +1,16 @@ +const { version, authorMeta, prettyName, repoUrl, license } = require('../repo.js'); +const { color, reset, donate, sniffcat } = require('./utils/helper.js'); +const logger = require('../logger.js'); + +module.exports = () => { + console.log(` + ${color('255;0;130')} _____ ____ _ + ${color('235;0;120')}|_ _| | _ \\ ___ | |_ ${color('235;0;120')}T${color('235;0;120')}-${color('235;0;120')}Pot To ${prettyName} - v${version} + ${color('215;0;110')} | | | |_) / _ \\| __| ${color('235;0;120')}${authorMeta} + ${color('195;0;95')} | | | __/ (_) | |_ ${repoUrl} [${license}] + ${color('175;0;85')} |_| |_| \\___/ \\__|${reset} +`); + + logger.info(donate); + logger.info(sniffcat); +}; diff --git a/scripts/banners/ufw.js b/scripts/banners/ufw.js new file mode 100644 index 0000000..4e064ee --- /dev/null +++ b/scripts/banners/ufw.js @@ -0,0 +1,17 @@ +const { version, authorMeta, prettyName, repoUrl, license } = require('../repo.js'); +const { color, reset, headerText, authorText, donate, sniffcat } = require('./utils/helper.js'); +const logger = require('../logger.js'); + +module.exports = () => { + console.log(` + ${color('255;140;0')}โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— + ${color('255;120;0')}โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ• โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ ${headerText(`UFW To ${prettyName} - v${version}`)} + ${color('255;100;0')}โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•‘ โ–ˆโ•— โ–ˆโ–ˆโ•‘ ${authorText(authorMeta)} + ${color('255;80;0')}โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ• โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ ${repoUrl} ${authorText(`[${license}]`)} + ${color('255;70;0')}โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ• โ–ˆโ–ˆโ•‘ โ•šโ–ˆโ–ˆโ–ˆโ•”โ–ˆโ–ˆโ–ˆโ•”โ• + ${color('255;40;0')} โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•šโ•โ•โ•${reset} +`); + + logger.info(donate); + logger.info(sniffcat); +}; diff --git a/scripts/banners/utils/gradientText.js b/scripts/banners/utils/gradientText.js new file mode 100644 index 0000000..4483c8b --- /dev/null +++ b/scripts/banners/utils/gradientText.js @@ -0,0 +1,7 @@ +module.exports = (text, startGreen, endGreen) => { + const textLength = text.length; + return [...text].map((char, index) => { + const greenValue = Math.round(startGreen - ((startGreen - endGreen) * index / (textLength - 1))); + return `\x1b[38;2;255;${greenValue};0m${char}`; + }).join(''); +}; \ No newline at end of file diff --git a/scripts/banners/utils/helper.js b/scripts/banners/utils/helper.js new file mode 100644 index 0000000..26b5e8f --- /dev/null +++ b/scripts/banners/utils/helper.js @@ -0,0 +1,19 @@ +const gradientText = require('./gradientText.js'); + +const IS_PM2 = 'pm_id' in process.env; + +const color = code => IS_PM2 ? '' : `\x1b[38;2;${code}m`; +const reset = IS_PM2 ? '' : '\x1b[0m'; + +const headerText = text => IS_PM2 ? text : gradientText(text, 145, 58); +const authorText = text => IS_PM2 ? text : gradientText(text, 145, 88); + +module.exports = Object.freeze({ + IS_PM2, + color, + reset, + headerText, + authorText, + donate: '๐Ÿ’— If you find this project useful, consider supporting me: https://sefinek.net/donate', + sniffcat: '๐Ÿ“˜ Consider reporting malicious addresses to SniffCat, a modern alternative to AbuseIPDB: https://sniffcat.com', +}); \ No newline at end of file diff --git a/scripts/cliHelp.js b/scripts/cliHelp.js new file mode 100644 index 0000000..ab98155 --- /dev/null +++ b/scripts/cliHelp.js @@ -0,0 +1,35 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +if (process.argv.includes('--help')) { + const readme = path.join(__dirname, '..', 'README.md'); + if (!fs.existsSync(readme)) { + console.log('No README.md found.\n'); + process.exit(1); + } + + const lines = fs.readFileSync(readme, 'utf8').split('\n'); + const start = lines.findIndex(l => l.startsWith('| `--')); + if (start === -1) { + console.log('No valid CLI options found in README.md.\n'); + process.exit(1); + } + + const options = []; + for (let i = start; i < lines.length && lines[i].startsWith('|'); i++) { + const cols = lines[i].split('|').map(v => v.trim()); + if (cols.length < 3) continue; + const name = cols[1].replace(/`/g, ''); + const desc = cols[2].replace(/\s+/g, ' '); + if (name && desc) options.push([name, desc]); + } + + const pad = Math.max(...options.map(([n]) => n.length)) + 2; + console.log('\nUsage: node . [options]\n\nOptions:'); + for (const [name, desc] of options) { + console.log(` ${name.padEnd(pad)}${desc}`); + } + console.log('\nExample: node . --run-on-start\n'); + + process.exit(0); +} \ No newline at end of file diff --git a/scripts/flags.js b/scripts/flags.js new file mode 100644 index 0000000..b9e1b7b --- /dev/null +++ b/scripts/flags.js @@ -0,0 +1,200 @@ +const { prettyName } = require('./repo.js'); +const logger = require('./logger.js'); + +/** + * @typedef { + * | 'DNS_COMPROMISE' + * | 'DNS_POISONING' + * | 'DDOS_ATTACK' + * | 'PORT_SCAN' + * | 'MASS_SCANNER' + * | 'EXPLOITED_HOST' + * | 'MALWARE_HOSTING' + * | 'CNC_BEACONING' + * | 'CRYPTOJACKING' + * | 'PHISHING' + * | 'HACKING' + * | 'SQL_INJECTION' + * | 'COMMAND_INJECTION' + * | 'SPAM_ACTIVITY' + * | 'BAD_WEB_BOT' + * | 'PATH_TRAVERSAL' + * | 'BRUTE_FORCE' + * | 'SSH' + * | 'FTP' + * | 'EMAIL' + * | 'HTTP' + * | 'RDP' + * | 'TELNET' + * | 'SMB' + * | 'MONGODB' + * | 'REDIS' + * | 'OTHER_ABUSE' + * | 'FRAUD_ORDERS' + * | 'PING_OF_DEATH' + * | 'FRAUD_VOIP' + * | 'OPEN_PROXY' + * | 'WEB_SPAM' + * | 'EMAIL_SPAM' + * | 'BLOG_SPAM' + * | 'VPN_IP' + * | 'SPOOFING' + * | 'WEB_APP_ATTACK' + * | 'IOT_TARGETED' + * | 'IGNORING_ROBOTS_TXT' + * } Flag + */ + +/** @type {Record} */ +const FLAGS = Object.freeze({ + DNS_COMPROMISE: 'DNS_COMPROMISE', + DNS_POISONING: 'DNS_POISONING', + DDOS_ATTACK: 'DDOS_ATTACK', + PORT_SCAN: 'PORT_SCAN', + MASS_SCANNER: 'MASS_SCANNER', + EXPLOITED_HOST: 'EXPLOITED_HOST', + MALWARE_HOSTING: 'MALWARE_HOSTING', + CNC_BEACONING: 'CNC_BEACONING', + CRYPTOJACKING: 'CRYPTOJACKING', + PHISHING: 'PHISHING', + HACKING: 'HACKING', + SQL_INJECTION: 'SQL_INJECTION', + COMMAND_INJECTION: 'COMMAND_INJECTION', + SPAM_ACTIVITY: 'SPAM_ACTIVITY', + BAD_WEB_BOT: 'BAD_WEB_BOT', + PATH_TRAVERSAL: 'PATH_TRAVERSAL', + BRUTE_FORCE: 'BRUTE_FORCE', + SSH: 'SSH', + FTP: 'FTP', + EMAIL: 'EMAIL', + HTTP: 'HTTP', + RDP: 'RDP', + TELNET: 'TELNET', + SMB: 'SMB', + MONGODB: 'MONGODB', + REDIS: 'REDIS', + OTHER_ABUSE: 'OTHER_ABUSE', + FRAUD_ORDERS: 'FRAUD_ORDERS', + PING_OF_DEATH: 'PING_OF_DEATH', + FRAUD_VOIP: 'FRAUD_VOIP', + OPEN_PROXY: 'OPEN_PROXY', + WEB_SPAM: 'WEB_SPAM', + EMAIL_SPAM: 'EMAIL_SPAM', + BLOG_SPAM: 'BLOG_SPAM', + VPN_IP: 'VPN_IP', + SPOOFING: 'SPOOFING', + WEB_APP_ATTACK: 'WEB_APP_ATTACK', + IOT_TARGETED: 'IOT_TARGETED', + IGNORING_ROBOTS_TXT: 'IGNORING_ROBOTS_TXT', +}); + +const MAPPINGS = Object.freeze({ + SniffCat: new Map([ + ['DNS_COMPROMISE', 1], + ['DNS_POISONING', 2], + ['DDOS_ATTACK', 3], + ['PORT_SCAN', 4], + ['MASS_SCANNER', 5], + ['EXPLOITED_HOST', 6], + ['MALWARE_HOSTING', 7], + ['CNC_BEACONING', 8], + ['CRYPTOJACKING', 9], + ['PHISHING', 10], + ['HACKING', 11], + ['SQL_INJECTION', 12], + ['COMMAND_INJECTION', 13], + ['SPAM_ACTIVITY', 14], + ['BAD_WEB_BOT', 15], + ['PATH_TRAVERSAL', 16], + ['BRUTE_FORCE', 17], + ['SSH', 18], + ['FTP', 19], + ['EMAIL', 20], + ['HTTP', 21], + ['RDP', 22], + ['TELNET', 23], + ['SMB', 24], + ['MONGODB', 25], + ['REDIS', 26], + ['OTHER_ABUSE', 27], + ]), + AbuseIPDB: new Map([ + ['DNS_COMPROMISE', 1], + ['DNS_POISONING', 2], + ['FRAUD_ORDERS', 3], + ['DDOS_ATTACK', 4], + ['FTP', 5], + ['PING_OF_DEATH', 6], + ['PHISHING', 7], + ['FRAUD_VOIP', 8], + ['OPEN_PROXY', 9], + ['WEB_SPAM', 10], + ['EMAIL_SPAM', 11], + ['BLOG_SPAM', 12], + ['VPN_IP', 13], + ['PORT_SCAN', 14], + ['HACKING', 15], + ['SQL_INJECTION', 16], + ['SPOOFING', 17], + ['BRUTE_FORCE', 18], + ['BAD_WEB_BOT', 19], + ['EXPLOITED_HOST', 20], + ['WEB_APP_ATTACK', 21], + ['SSH', 22], + ['IOT_TARGETED', 23], + ]), +}); + +const FALLBACK_IDS = { + SniffCat: MAPPINGS.SniffCat.get(FLAGS.OTHER_ABUSE), + AbuseIPDB: MAPPINGS.AbuseIPDB.get(FLAGS.HACKING), +}; + +const createFlagCollection = () => { + const flags = new Set(); + + const list = () => Array.from(flags); + + const toIDs = (integration = prettyName) => { + const map = MAPPINGS[integration]; + if (!map) throw new Error(`Unsupported integration: ${integration}`); + + const fallbackID = FALLBACK_IDS[integration]; + if (typeof fallbackID !== 'number') { + throw new Error(`No fallback ID defined for integration: ${integration}`); + } + + const currentFlags = list(); + if (currentFlags.length === 0) return [fallbackID]; + + const ids = currentFlags + .map(f => map.get(f) ?? fallbackID) + .filter(id => typeof id === 'number'); + + return ids.length > 0 ? ids : [fallbackID]; + }; + + const api = { + add: /** @param {...Flag} items */ (...items) => { + for (const item of items) { + if (Object.prototype.hasOwnProperty.call(FLAGS, item)) { + flags.add(item); + } else { + logger.warn(`Invalid abuse flag ignored! flag='${item}' integration='${prettyName}'`, { discord: true, ping: false }); + } + } + return api; + }, + list, + toIDs, + toString: (integration = prettyName) => toIDs(integration).join(','), + }; + + return api; +}; + +module.exports = { + /** @type {Record} */ + FLAGS, + createFlagCollection, +}; \ No newline at end of file diff --git a/scripts/flags.test.js b/scripts/flags.test.js new file mode 100644 index 0000000..7729f78 --- /dev/null +++ b/scripts/flags.test.js @@ -0,0 +1,32 @@ +const { createFlagCollection } = require('./flags.js'); + +const flags = createFlagCollection(); + +// No flags added โ€” expect default fallbacks: +console.log(flags.toIDs('SniffCat')); // [27] +console.log(flags.toIDs('AbuseIPDB')); // [15] +// console.log(flags.toIDs('UnknownSystem')); + +// Add some flags that are mapped in both systems: +flags.add('DDOS_ATTACK', 'PHISHING', 'PORT_SCAN'); + +console.log(flags.list()); // ['DDOS_ATTACK', 'PHISHING', 'PORT_SCAN'] + +console.log(flags.toIDs('SniffCat')); // [3, 10, 4] +console.log(flags.toIDs('AbuseIPDB')); // [4, 7, 14] +console.log(flags.toString('AbuseIPDB')); // "4,7,14" + +// Add a flag that doesn't exist in AbuseIPDB: +flags.add('EMAIL'); // Present in SniffCat only + +console.log(flags.toIDs('SniffCat')); // [3, 10, 4, 20] +console.log(flags.toIDs('AbuseIPDB')); // [4, 7, 14, 15] โ† fallback 15 for 'EMAIL' + +// Add a flag that only exists in AbuseIPDB: +flags.add('FRAUD_VOIP'); // Present in AbuseIPDB only + +console.log(flags.toIDs('AbuseIPDB')); // [4, 7, 14, 15, 8] +console.log(flags.toIDs('SniffCat')); // [3, 10, 4, 20, 27] โ† fallback 27 for 'FRAUD_VOIP' + +// Unknown integration +// console.log(flags.toIDs('UnknownSystem')); // [] \ No newline at end of file diff --git a/scripts/formatTimestamp.js b/scripts/formatTimestamp.js new file mode 100644 index 0000000..41d0e3e --- /dev/null +++ b/scripts/formatTimestamp.js @@ -0,0 +1,10 @@ +module.exports = iso => { + try { + const date = new Date(iso); + if (isNaN(date.getTime())) throw new Error('Invalid Date'); + + return date.toISOString().split('.')[0] + 'Z'; + } catch { + return new Date().toISOString().split('.')[0] + 'Z'; + } +}; \ No newline at end of file diff --git a/scripts/honeypots/cowrie.js b/scripts/honeypots/cowrie.js new file mode 100644 index 0000000..306fde5 --- /dev/null +++ b/scripts/honeypots/cowrie.js @@ -0,0 +1,271 @@ +const { FLAGS, createFlagCollection } = require('../flags.js'); +const logIpToFile = require('../logIpToFile.js'); +const tailFile = require('../services/tailFile.js'); +const logger = require('../logger.js'); +const resolvePath = require('../pathResolver.js'); +const { COWRIE_LOG_FILE, SERVER_ID } = require('../../config.js').MAIN; + +const LOG_FILE = resolvePath(COWRIE_LOG_FILE); +const REPORT_DELAY = SERVER_ID === 'development' ? 30 * 1000 : 10 * 60 * 1000; + +const CREDS_LIMIT = 885; +const ipBuffers = new Map(); + +const extractSessionData = sessions => { + const commands = []; + const flags = createFlagCollection(); + + const credsSet = new Set(); + const fingerprints = new Set(); + const uploads = new Set(); + const tunnels = new Set(); + + let dpt = null, proto = null, sshVersion = null, timestamp = null; + const downloadUrls = new Set(); + + for (const s of sessions) { + dpt ??= s.dpt; + proto ??= s.proto; + sshVersion ??= s.sshVersion; + timestamp ??= s.timestamp; + + s.credentials?.forEach((_, cred) => credsSet.add(cred)); + commands.push(...s.commands); + + if (s.fingerprint) fingerprints.add(s.fingerprint); + if (s.uploads) s.uploads.forEach(f => uploads.add(f)); + if (s.tunnels) s.tunnels.forEach(t => tunnels.add(t)); + if (s.download?.url) { + flags.add(FLAGS.WEB_APP_ATTACK); + downloadUrls.add(s.download.url); + } + } + + const creds = [...credsSet]; + const loginAttempts = creds.length; + const cmdCount = commands.length; + + if (loginAttempts >= 2) flags.add(FLAGS.BRUTE_FORCE); + if (proto === 'ssh') flags.add(FLAGS.SSH); + if (proto === 'telnet') flags.add(FLAGS.TELNET); + if (cmdCount) flags.add(FLAGS.EMAIL); + if (!loginAttempts && !cmdCount) flags.add(FLAGS.PORT_SCAN); + + return { + dpt, proto, sshVersion, timestamp, creds, commands, + categories: flags.toString(), + downloadUrls: [...downloadUrls], + fingerprints: [...fingerprints], + uploads: [...uploads], + tunnels: [...tunnels], + }; +}; + +const buildComment = ({ serverId, dpt, proto, creds, commands, sshVersion, downloadUrls, fingerprints, uploads, tunnels }, full = false) => { + const loginAttempts = creds.length; + const cmdCount = commands.length; + const lines = []; + + lines.push(`Honeypot ${serverId ? `[${serverId}]` : 'hit'}: ${loginAttempts ? 'Brute-force attack' : 'Unauthorized connection attempt'} detected on ${dpt}/${proto.toUpperCase()}`); + + if (loginAttempts === 1) { + lines.push(`โ€ข Credential used: ${creds[0]}`); + } else if (loginAttempts > 1) { + let joined = creds.join(', '); + if (!full && joined.length > CREDS_LIMIT) joined = joined.slice(0, CREDS_LIMIT).replace(/,[^,]*$/, '') + '...'; + lines.push(`โ€ข Credentials: ${joined}`); + } + + if (loginAttempts) lines.push(`โ€ข Number of login attempts: ${loginAttempts}`); + if (cmdCount) lines.push(`โ€ข ${cmdCount} command(s) were executed during the session`); + if (sshVersion) lines.push(`โ€ข Client: ${sshVersion}`); + if (downloadUrls.length) lines.push(`โ€ข Suspicious file URLs: ${downloadUrls.join(', ')}`); + if (fingerprints.length) lines.push(`โ€ข SSH key fingerprints: ${fingerprints.join(', ')}`); + if (uploads.length) lines.push(`โ€ข Uploaded files: ${uploads.join(', ')}`); + if (tunnels.length) lines.push(`โ€ข TCP tunnels: ${tunnels.join(', ')}`); + + return lines.join('\n'); +}; + +const flushBuffer = async (srcIp, reportIp) => { + const buffer = ipBuffers.get(srcIp); + if (!buffer) return; + + clearTimeout(buffer.timer); + ipBuffers.delete(srcIp); + + const sessions = buffer.sessions || []; + if (!sessions.length) return; + + const { + dpt, proto, sshVersion, timestamp, creds, commands, + categories, downloadUrls, fingerprints, uploads, tunnels, + } = extractSessionData(sessions); + + if (!srcIp || !dpt || !proto) { + return logger.warn(`COWRIE -> Incomplete data for ${srcIp}, discarded`); + } + + const shortComment = buildComment({ serverId: SERVER_ID, dpt, proto, creds, commands, sshVersion, downloadUrls, fingerprints, uploads, tunnels }, false); + const [, ...restLines] = shortComment.split('\n'); + const rest = restLines.length ? `\n${restLines.join('\n')}` : ''; + + await reportIp('COWRIE', { srcIp, dpt, proto, timestamp }, categories, shortComment); + if (restLines.length) await logger.info(rest); + await logger.webhook(`### Cowrie: ${srcIp} on ${dpt}/${proto}${rest}`); + + logIpToFile(srcIp, { honeypot: 'COWRIE', comment: buildComment({ serverId: SERVER_ID, dpt, proto, creds, commands, sshVersion, downloadUrls, fingerprints, uploads, tunnels }, true) }); +}; + +const processCowrieLogLine = async (entry, reportIp) => { + const ip = entry?.src_ip; + const sessionId = entry?.session; + const { eventid } = entry; + if (!ip || !eventid || !sessionId) return logger.warn('COWRIE -> Skipped: missing src_ip, eventid or sessionId'); + + let buffer = ipBuffers.get(ip); + if (!buffer) { + buffer = { + sessions: [], + timer: setTimeout(() => flushBuffer(ip, reportIp), REPORT_DELAY), + lastSeen: Date.now(), + }; + ipBuffers.set(ip, buffer); + } else { + buffer.lastSeen = Date.now(); + } + + let session = buffer.sessions.find(s => s.sessionId === sessionId); + if (!session && eventid !== 'cowrie.session.closed') { + session = { + sessionId, + srcIp: ip, + dpt: null, + proto: null, + timestamp: null, + credentials: new Map(), + commands: [], + sshVersion: null, + download: null, + fingerprint: null, + uploads: [], + tunnels: [], + }; + buffer.sessions.push(session); + } + + switch (eventid) { + case 'cowrie.session.connect': + if (session) { + session.dpt = entry.dst_port; + session.proto = entry.protocol; + session.timestamp = entry.timestamp; + logger.info(`COWRIE -> ${ip}/${session.proto}/${session.dpt}: Connect`); + } + break; + + case 'cowrie.login.success': + case 'cowrie.login.failed': + if (session && (entry.username || entry.password)) { + session.credentials.set(`${entry.username}:${entry.password}`, true); + const status = eventid === 'cowrie.login.success' ? 'Connected' : 'Failed login'; + logger.info(`COWRIE -> ${ip}/${session.proto}/${session.dpt}: ${status} ยป ${entry.username}:${entry.password}`); + } + break; + + case 'cowrie.client.version': + if (session) { + session.sshVersion = entry.version; + logger.info(`COWRIE -> ${ip}/${session.proto}/${session.dpt}: SSH version ยป ${entry.version}`); + } + break; + + case 'cowrie.command.input': + if (session && entry.input) { + session.commands.push(entry.input); + logger.info(`COWRIE -> ${ip}/${session.proto}/${session.dpt}: $ ${entry.input}`); + } + break; + + case 'cowrie.session.file_download': + if (session && entry.url) { + session.commands.push(`[download] ${entry.url}`); + session.download = { url: entry.url, outfile: entry.outfile }; + logger.info(`COWRIE -> ${ip}/${session.proto}/${session.dpt}: File download ยป ${entry.url}`); + } + break; + + case 'cowrie.client.fingerprint': + if (session && entry.fingerprint) { + session.fingerprint = entry.fingerprint; + logger.info(`COWRIE -> ${ip}/${session.proto}/${session.dpt}: SSH key fingerprint ยป ${entry.fingerprint}`); + } + break; + + case 'cowrie.session.file_upload': + if (session && entry.filename) { + session.uploads.push(entry.filename); + logger.info(`COWRIE -> ${ip}/${session.proto}/${session.dpt}: File upload ยป ${entry.filename}`); + } + break; + + case 'cowrie.direct-tcpip.request': + if (session && entry.dst_ip && entry.dst_port) { + const tunnel = `${entry.dst_ip}:${entry.dst_port}`; + session.tunnels.push(tunnel); + logger.info(`COWRIE -> ${ip}/${session.proto}/${session.dpt}: TCP tunnel request ยป ${tunnel}`); + } + break; + + case 'cowrie.session.closed': + logger.info(`COWRIE -> ${ip}/${session?.proto ?? 'unknown'}/${session?.dpt ?? '-'}: Session ${sessionId} closed`); + break; + } +}; + +module.exports = reportIp => { + tailFile(LOG_FILE, async line => { + if (!line.length) return; + + let entry; + try { + entry = JSON.parse(line); + } catch (err) { + return logger.error(`COWRIE -> JSON parse error: ${err.message}\nFaulty line: ${JSON.stringify(line)}`); + } + + try { + await processCowrieLogLine(entry, reportIp); + } catch (err) { + logger.error(err); + } + }, { label: 'COWRIE' }).catch(err => logger.error(err)); + + // Clean buffer + const cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [ip, buffer] of ipBuffers.entries()) { + if (now - buffer.lastSeen > 30 * 60 * 1000) { + clearTimeout(buffer.timer); + ipBuffers.delete(ip); + logger.warn(`COWRIE -> Cleaned up stale session buffer for ${ip}`); + } + } + }, 15 * 60 * 1000); + + logger.success('๐Ÿ›ก๏ธ COWRIE ยป Watcher initialized'); + return { + flush: async () => { + for (const ip of ipBuffers.keys()) { + await flushBuffer(ip, reportIp); + } + }, + cleanup: () => { + clearInterval(cleanupInterval); + for (const buffer of ipBuffers.values()) { + clearTimeout(buffer.timer); + } + ipBuffers.clear(); + }, + }; +}; diff --git a/scripts/honeypots/dionaea.js b/scripts/honeypots/dionaea.js new file mode 100644 index 0000000..b27f815 --- /dev/null +++ b/scripts/honeypots/dionaea.js @@ -0,0 +1,97 @@ +const { FLAGS, createFlagCollection } = require('../flags.js'); +const logIpToFile = require('../logIpToFile.js'); +const tailFile = require('../services/tailFile.js'); +const logger = require('../logger.js'); +const resolvePath = require('../pathResolver.js'); +const { DIONAEA_LOG_FILE, SERVER_ID } = require('../../config.js').MAIN; + +const LOG_FILE = resolvePath(DIONAEA_LOG_FILE); + +const getReportDetails = (entry, dpt) => { + const proto = entry?.connection?.protocol || 'unknown'; + const timestamp = entry?.timestamp || new Date().toISOString(); + + const flags = createFlagCollection(); + let comment; + + switch (proto) { + case 'mssqld': { + const username = entry?.credentials?.username?.[0]; + const password = entry?.credentials?.password?.[0]; + if (username && !password) { + flags.add(FLAGS.BRUTE_FORCE); + comment = `MSSQL traffic (on ${dpt}) with username ${username} and empty password`; + } else if (username && password) { + flags.add(FLAGS.BRUTE_FORCE); + comment = `MSSQL traffic (on ${dpt}) with credentials ${username}:${password}`; + } else { + flags.add(FLAGS.PORT_SCAN); + comment = `MSSQL traffic (on ${dpt}) without login credentials`; + } + break; + } + case 'httpd': + flags.add(FLAGS.HTTP, FLAGS.BAD_WEB_BOT); + comment = `Incoming HTTP traffic on port ${dpt}`; + break; + case 'ftp': + flags.add(FLAGS.FTP, FLAGS.BRUTE_FORCE); + comment = `FTP brute-force or probing on port ${dpt}`; + break; + case 'smbd': + flags.add(FLAGS.SMB); + comment = `SMB traffic on port ${dpt}`; + break; + case 'mysql': + flags.add(FLAGS.BRUTE_FORCE); + comment = `MySQL brute-force or probing on port ${dpt}`; + break; + case 'tftp': + flags.add(FLAGS.EXPLOITED_HOST); + comment = `TFTP protocol traffic on ${dpt}`; + break; + case 'upnp': + case 'mqtt': + flags.add(FLAGS.IOT_TARGETED); + comment = `Unauthorized ${proto.toUpperCase()} traffic on ${dpt}`; + break; + default: + flags.add(FLAGS.PORT_SCAN); + comment = `Unauthorized traffic on ${dpt}/${proto}`; + } + + return { + proto: proto.toUpperCase(), + comment: `Honeypot ${SERVER_ID ? `[${SERVER_ID}]` : 'hit'}: ${comment}`, + categories: flags.toString(), + timestamp, + }; +}; + +module.exports = reportIp => { + tailFile(LOG_FILE, async line => { + if (!line.length) return; + + let entry; + try { + entry = JSON.parse(line); + } catch (err) { + return logger.error(`DIONAEA -> JSON parse error: ${err.message}\nFaulty line: ${JSON.stringify(line)}`); + } + + try { + const srcIp = entry?.src_ip; + const dpt = entry?.dst_port; + if (!srcIp || !dpt) return; + + const { proto, timestamp, categories, comment } = getReportDetails(entry, dpt); + await reportIp('DIONAEA', { srcIp, dpt, proto, timestamp }, categories, comment); + + await logIpToFile(srcIp, { honeypot: 'DIONAEA', comment }); + } catch (err) { + logger.error(err); + } + }, { label: 'DIONAEA' }).catch(err => logger.error(err)); + + logger.success('๐Ÿ›ก๏ธ DIONAEA ยป Watcher initialized'); +}; diff --git a/scripts/honeypots/honeytrap.js b/scripts/honeypots/honeytrap.js new file mode 100644 index 0000000..c4fa861 --- /dev/null +++ b/scripts/honeypots/honeytrap.js @@ -0,0 +1,179 @@ +const logIpToFile = require('../logIpToFile.js'); +const tailFile = require('../services/tailFile.js'); +const logger = require('../logger.js'); +const resolvePath = require('../pathResolver.js'); +const { HONEYTRAP_LOG_FILE, SERVER_ID } = require('../../config.js').MAIN; +const { FLAGS, createFlagCollection } = require('../flags.js'); + +const LOG_FILE = resolvePath(HONEYTRAP_LOG_FILE); +const HEADER_PRIORITY = ['user-agent', 'accept', 'accept-language', 'accept-encoding']; + +let lastFlushTime = Date.now(); +const attackBuffer = new Map(); + +const capitalizeHeader = header => header.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join('-'); + +const parseHttpRequest = (ascii, dpt) => { + const lines = ascii.replace(/\r\n|\r/g, '\n').trim().split('\n'); + + const requestLineRaw = lines.shift()?.trim() || ''; + const protocol = requestLineRaw.match(/HTTP\/[0-9.]+/i)?.[0]?.toUpperCase() || 'HTTP'; + const requestLine = requestLineRaw.replace(/\s*HTTP\/[0-9.]+$/i, ''); + + const headers = {}; + const body = []; + let parsingBody = false; + + for (const line of lines) { + if (parsingBody) { + body.push(line); + continue; + } + + if (!line.trim()) { + parsingBody = true; + continue; + } + + const [key, ...value] = line.split(':'); + if (value.length && key.toLowerCase() !== 'host') headers[key.trim().toLowerCase()] = value.join(':').trim(); + } + + const formattedHeaders = HEADER_PRIORITY + .filter(h => headers[h]) + .map(h => `${capitalizeHeader(h)}: ${headers[h]}`) + .join('\n'); + + let output = `${protocol} request on ${dpt}\n\n${requestLine}`; + if (formattedHeaders) output += `\n${formattedHeaders}`; + + if (requestLineRaw.startsWith('POST')) { + const bodyContent = body.join('\n').trim(); + if (bodyContent) output += `\nPOST Data: ${bodyContent}`; + } + + return output; +}; + +const getReportDetails = (entry, dpt) => { + const proto = entry?.attack_connection?.protocol || 'unknown'; + const hex = entry?.attack_connection?.payload?.data_hex || ''; + const ascii = Buffer.from(hex, 'hex').toString('utf8'); + const simplifiedAscii = ascii.replace(/\s+/g, ' ').toLowerCase(); + const payloadLen = entry?.attack_connection?.payload?.length || 0; + + const flags = createFlagCollection(); + let comment; + + switch (true) { + case payloadLen === 0: + flags.add(FLAGS.PORT_SCAN); + comment = `Empty payload on ${dpt}/${proto} (likely service probe)`; + break; + case payloadLen > 1000: + flags.add(FLAGS.BAD_WEB_BOT); + comment = `Large payload (${payloadLen} bytes) on ${dpt}/${proto}`; + break; + case (/HTTP\/(0\.9|1\.0|1\.1|2|3)/i).test(simplifiedAscii): + flags.add(FLAGS.HTTP, FLAGS.BAD_WEB_BOT); + comment = parseHttpRequest(ascii, dpt); + break; + case (/\bssh\b/).test(simplifiedAscii): + flags.add(FLAGS.SSH); + comment = `SSH handshake/banner on ${dpt}/${proto} (${payloadLen} bytes of payload)`; + break; + case simplifiedAscii.includes('cookie:'): + flags.add(FLAGS.HTTP, FLAGS.BAD_WEB_BOT); + comment = `HTTP header with cookie on ${dpt}/${proto}`; + break; + case (/(admin|root|wget|curl|bash|eval|php|bin)/).test(simplifiedAscii): + flags.add(FLAGS.COMMAND_INJECTION); + comment = `Suspicious payload on ${dpt}/${proto} (possible command injection)`; + break; + default: + flags.add(FLAGS.PORT_SCAN); + comment = `Unauthorized traffic on ${dpt}/${proto} (${payloadLen} bytes of payload)`; + break; + } + + return { proto, baseComment: comment, categories: flags.toString(), timestamp: entry?.['@timestamp'] }; +}; + +const flushBuffer = async reportIp => { + if (!attackBuffer.size) return; + + for (const [srcIp, ports] of attackBuffer.entries()) { + const sortedPorts = Array.from(ports.entries()) + .sort(([, a], [, b]) => b.count - a.count) + .slice(0, 6); + + const proto = sortedPorts[0][1].proto || 'tcp'; + const timestamp = sortedPorts[0][1].timestamp; + const baseComment = sortedPorts[0][1].baseComment; + const categories = sortedPorts[0][1].categories; + + const portSummary = sortedPorts.map(([port, data]) => `${port} [${data.count}]`).join(', '); + const comment = `Honeypot ${SERVER_ID ? `[${SERVER_ID}]` : 'hit'}: ${baseComment.replace(/ on \d+\/\w+/, '')}; ${portSummary} ${proto.toUpperCase()}`; + + await reportIp('HONEYTRAP', { srcIp, dpt: sortedPorts[0][0], proto, timestamp }, categories, comment); + await logIpToFile(srcIp, { honeypot: 'HONEYTRAP', comment }); + } + + logger.success(`HONEYTRAP -> Flushed ${attackBuffer.size} IPs`); + attackBuffer.clear(); +}; + +module.exports = reportIp => { + tailFile(LOG_FILE, async line => { + if (!line.length) return; + + let entry; + try { + entry = JSON.parse(line); + } catch (err) { + return logger.error(`HONEYTRAP -> JSON parse error: ${err.message}\nFaulty line: ${JSON.stringify(line)}`); + } + + try { + const srcIp = entry?.attack_connection?.remote_ip; + const dpt = entry?.attack_connection?.local_port; + if (!srcIp || !dpt) return; + + let ipData = attackBuffer.get(srcIp); + if (!ipData) { + ipData = new Map(); + attackBuffer.set(srcIp, ipData); + } + + const { proto, timestamp, categories, baseComment } = getReportDetails(entry, dpt); + let portData = ipData.get(dpt); + if (portData) { + portData.count++; + } else { + portData = { count: 1, proto, timestamp, categories, baseComment }; + ipData.set(dpt, portData); + } + + logger.info(`HONEYTRAP -> ${srcIp} hit ${dpt} | x${portData.count}`); + } catch (err) { + logger.error(err); + } + }, { label: 'HONEYTRAP' }).catch(err => logger.error(err)); + + // Clean buffer + const cleanupInterval = setInterval(async () => { + if (Date.now() >= lastFlushTime + 5 * 60 * 1000) { + await flushBuffer(reportIp); + lastFlushTime = Date.now(); + } + }, 60 * 1000); + + logger.success('๐Ÿ›ก๏ธ HONEYTRAP ยป Watcher initialized'); + return { + flush: () => flushBuffer(reportIp), + cleanup: () => { + clearInterval(cleanupInterval); + attackBuffer.clear(); + }, + }; +}; diff --git a/scripts/ipSanitizer.js b/scripts/ipSanitizer.js new file mode 100644 index 0000000..7b58b9b --- /dev/null +++ b/scripts/ipSanitizer.js @@ -0,0 +1,16 @@ +const { getServerIPs } = require('./services/ipFetcher.js'); + +let ipPattern = null; + +const buildPattern = () => { + const ips = getServerIPs(); + if (!ips.length) return null; + + const escaped = ips.map(i => i.replace(/[.:\\[\](){}^$*+?|]/g, '\\$&')); + return new RegExp(escaped.join('|'), 'g'); +}; + +module.exports = str => { + if (ipPattern === null) ipPattern = buildPattern() || false; + return ipPattern ? str.replace(ipPattern, '[SOME-IP]') : str; +}; \ No newline at end of file diff --git a/scripts/isSpecialPurposeIP.js b/scripts/isSpecialPurposeIP.js new file mode 100644 index 0000000..1393018 --- /dev/null +++ b/scripts/isSpecialPurposeIP.js @@ -0,0 +1,16 @@ +const ipaddr = require('ipaddr.js'); + +const LOCAL_RANGES = new Set([ + 'unspecified', 'multicast', 'linkLocal', 'loopback', 'reserved', 'benchmarking', 'amt', + 'broadcast', 'carrierGradeNat', 'private', 'as112', + 'uniqueLocal', 'ipv4Mapped', 'rfc6145', '6to4', 'teredo', 'as112v6', 'orchid2', 'droneRemoteIdProtocolEntityTags', +]); + +module.exports = ip => { + try { + const range = ipaddr.parse(ip).range(); + return { is: LOCAL_RANGES.has(range), range }; + } catch { + return { is: false, range: null }; + } +}; diff --git a/scripts/logIpToFile.js b/scripts/logIpToFile.js new file mode 100644 index 0000000..cf3130f --- /dev/null +++ b/scripts/logIpToFile.js @@ -0,0 +1,25 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const ipSanitizer = require('./ipSanitizer.js'); +const logger = require('../scripts/logger.js'); +const resolvePath = require('./pathResolver.js'); +const { LOG_IP_HISTORY_ENABLED, LOG_IP_HISTORY_DIR } = require('../config.js').MAIN; + +const BASE_DIR = resolvePath(LOG_IP_HISTORY_DIR); + +module.exports = async (ip, metadata = {}) => { + if (!LOG_IP_HISTORY_ENABLED || !ip) return; + + const { honeypot = 'unknown', comment } = metadata; + const logEntry = `[${new Date().toISOString()}] ${ipSanitizer(comment)}\n`; + + const ipDir = path.join(BASE_DIR, ip); + const logFile = path.join(ipDir, `${honeypot.toUpperCase()}.txt`); + + try { + await fs.mkdir(ipDir, { recursive: true }); + await fs.appendFile(logFile, logEntry); + } catch (err) { + logger.error(`Failed to write IP log for ${ip}: ${err.message}`); + } +}; diff --git a/scripts/logger.js b/scripts/logger.js new file mode 100644 index 0000000..bea9f5c --- /dev/null +++ b/scripts/logger.js @@ -0,0 +1,151 @@ +const { reset, IS_PM2 } = require('./banners/utils/helper.js'); +const { axiosWebhook } = require('./services/axios.js'); +const repoInfo = require('./repo.js'); +const { SERVER_ID, DISCORD_WEBHOOK_ENABLED, DISCORD_WEBHOOK_URL, DISCORD_WEBHOOK_USERNAME, DISCORD_USER_ID } = require('../config.js').MAIN; + +const LEVELS = Object.freeze({ + 0: { method: 'log', label: '[i]', color: '\x1b[38;5;38m', hex: 0x00AEEF, name: 'INFO' }, + 1: { method: 'log', label: '[โœ“]', color: '\x1b[38;5;77m', hex: 0x3CB371, name: 'SUCCESS' }, + 2: { method: 'warn', label: '[!]', color: '\x1b[38;5;208m', hex: 0xFF8C00, name: 'WARNING' }, + 3: { method: 'error', label: '[X]', color: '\x1b[38;5;160m', hex: 0xD32F2F, name: 'ERROR' }, +}); + +const WEBHOOK_BURST_LIMIT = 3, WEBHOOK_COOLDOWN = 3000; +const webhookQueue = []; +let webhookBurstCount = 0, webhookLastSent = 0; + +class Logger { + static #getLevel(level) { + return LEVELS[level] ?? LEVELS[0]; + } + + static #normalizeOptions(options, defaultDiscord, defaultPing) { + if (typeof options === 'boolean') return { discord: defaultDiscord, ping: options }; + return { + discord: options.discord ?? defaultDiscord, + ping: options.ping ?? defaultPing, + }; + } + + static print(msg, level = 0) { + const { method, label, color: lvlColor } = this.#getLevel(level); + const fn = console[method] || console.log; + + if (IS_PM2) { + fn(`${label} ${msg}`); + } else { + fn(`${lvlColor}${label} ${msg}${reset}`); + } + } + + static async #sendToDiscord(msg, hex, ping) { + if (!msg || !DISCORD_WEBHOOK_ENABLED || !DISCORD_WEBHOOK_URL) return; + + if (typeof msg !== 'string') msg = msg.toString(); + + try { + const res = await axiosWebhook.post(DISCORD_WEBHOOK_URL, { + username: DISCORD_WEBHOOK_USERNAME === 'SERVER_ID' ? SERVER_ID : DISCORD_WEBHOOK_USERNAME || null, + content: (ping && DISCORD_USER_ID) ? `**[<@${DISCORD_USER_ID}>]**` : undefined, + embeds: [{ + description: msg + .replace(/(\b\w+=)/g, '**$1**') + .replace(/'/g, '`') + .trim(), + color: hex ?? 0x008FD1, + footer: { + text: `${SERVER_ID ? `${SERVER_ID} โ€ข ` : ''}${repoInfo.repoSlug} [v${repoInfo.version}]`, + }, + timestamp: new Date().toISOString(), + }], + }); + + if (res.status !== 204) { + this.print(`Failed to deliver Discord Webhook (unexpected status code: ${res.status})`, 2); + } + } catch (err) { + this.print(`Discord Webhook Error: ${err.message}`, 3); + } + } + + static async webhook(msg, levelOrHex = 0, ping = false) { + const isLevel = typeof levelOrHex === 'number' && levelOrHex >= 0 && levelOrHex <= 3; + const hex = isLevel ? this.#getLevel(levelOrHex).hex : levelOrHex; + + const now = Date.now(); + if (now - webhookLastSent > WEBHOOK_COOLDOWN) webhookBurstCount = 0; + + if (webhookBurstCount >= WEBHOOK_BURST_LIMIT) { + webhookQueue.push({ msg, hex, ping }); + if (webhookQueue.length === 1) { + this.print('Discord webhook rate limit reached. Some messages were scheduled for later delivery.', 2); + setTimeout(() => this.#processWebhookQueue(), WEBHOOK_COOLDOWN); + } + return; + } + + try { + await this.#sendToDiscord(msg, hex, ping); + webhookLastSent = now; + webhookBurstCount++; + + if (webhookQueue.length > 0) setTimeout(() => this.#processWebhookQueue(), WEBHOOK_COOLDOWN); + } catch (err) { + this.print(`Discord webhook delivery failed: ${err.message}`, 3); + } + } + + static async #processWebhookQueue() { + if (webhookQueue.length === 0) return; + + if (Date.now() - webhookLastSent > WEBHOOK_COOLDOWN) webhookBurstCount = 0; + + const { msg, hex, ping } = webhookQueue.shift(); + this.print(`Processing queued webhook (${webhookQueue.length} remaining)`, 0); + + try { + await this.#sendToDiscord(msg, hex, ping); + webhookLastSent = Date.now(); + webhookBurstCount = 1; + } catch (err) { + this.print(`Queued webhook failed: ${err.message}`, 3); + } + + if (webhookQueue.length > 0) setTimeout(() => this.#processWebhookQueue(), WEBHOOK_COOLDOWN); + } + + static log(msg, level = 0, options = {}) { + this.print(msg, level); + + if (options.discord) { + this.webhook(msg, level, options.ping) + .catch(err => console.error('[Logger] Webhook error:', err.message)); + } + } + + static info(msg, options = {}) { + const opts = this.#normalizeOptions(options, false, false); + this.log(msg, 0, opts); + } + + static success(msg, options = {}) { + const opts = this.#normalizeOptions(options, false, false); + this.log(msg, 1, opts); + } + + static warn(msg, options = {}) { + const opts = this.#normalizeOptions(options, true, false); + this.log(msg, 2, opts); + } + + static error(msg, options = {}) { + const opts = this.#normalizeOptions(options, true, false); + this.log(msg, 3, opts); + } + + static getWebhookQueueSize() { + return webhookQueue.length; + } +} + +module.exports = Logger; diff --git a/scripts/logger.test.js b/scripts/logger.test.js new file mode 100644 index 0000000..4c940d4 --- /dev/null +++ b/scripts/logger.test.js @@ -0,0 +1,25 @@ +const logger = require('./logger.js'); + +console.log('=== Testing Logger Rate Limiting ===\n'); + +// Test 1: Basic logging with timestamps +console.log('Test 1: Basic logging'); +logger.info('This is info message', { ping: true }); +logger.success('This is success message'); +logger.warn('This is warning message'); +logger.error('This is error message'); + +console.log('\n--- Waiting 1 second ---\n'); + +setTimeout(async () => { + console.log('Test 2: Webhook queue (simulated)'); + + // Simulate rapid webhook calls + for (let i = 1; i <= 6; i++) { + await logger.webhook(`Test webhook ${i}`, 0, false); + } + + console.log(`\nWebhook queue size: ${logger.getWebhookQueueSize()}`); + + setTimeout(() => process.exit(0), 40000); +}, 1000); diff --git a/scripts/pathResolver.js b/scripts/pathResolver.js new file mode 100644 index 0000000..26e459c --- /dev/null +++ b/scripts/pathResolver.js @@ -0,0 +1,12 @@ +const os = require('node:os'); +const path = require('node:path'); + +module.exports = inputPath => { + if (typeof inputPath !== 'string') return inputPath; + + // Replace tilde with home directory + const expandedPath = inputPath.replace(/^~(?=$|\/|\\)/, os.homedir()); + + // Return absolute path + return path.resolve(expandedPath); +}; \ No newline at end of file diff --git a/scripts/repo.js b/scripts/repo.js new file mode 100644 index 0000000..aa10424 --- /dev/null +++ b/scripts/repo.js @@ -0,0 +1,33 @@ +const { version, author: authorMeta, repository, license } = require('../package.json'); + +const githubRepoRegex = /github\.com[:/]+([^/]+)\/([^/#.]+)(?:\.git)?/; +const [, author = '', name = ''] = repository?.url?.match(githubRepoRegex) || []; + +const slug = `${author}/${name}`; +const url = `https://github.com/${slug}`; + +const nameMappings = { + sniffcat: 'SniffCat', + abuseipdb: 'AbuseIPDB', +}; + +const getPrettyName = repo => { + const repoLower = repo.toLowerCase(); + return Object.entries(nameMappings) + .reduce((best, [key, value]) => + repoLower.includes(key.toLowerCase()) && key.length > best.key.length + ? { key, value } + : best, + { key: '', value: repo }).value; +}; + +module.exports = Object.freeze({ + version, + repoAuthor: author, + authorMeta, + repoName: name, + prettyName: getPrettyName(name), + repoSlug: slug, + repoUrl: url, + license, +}); \ No newline at end of file diff --git a/scripts/services/axios.js b/scripts/services/axios.js new file mode 100644 index 0000000..1880cbb --- /dev/null +++ b/scripts/services/axios.js @@ -0,0 +1,104 @@ +const axios = require('axios'); +const { version, repoName, repoUrl } = require('../repo.js'); +const { SERVER_ID, EXTENDED_LOGS, SNIFFCAT_API_KEY, ABUSEIPDB_API_KEY, SEFIN_API_SECRET_TOKEN, CLOUDFLARE_API_KEY } = require('../../config.js').MAIN; + +// Lazy-load logger +let logger; +const getLogger = () => logger || (logger = require('../logger.js')); + +const DEFAULT_HEADERS = { + 'User-Agent': `Mozilla/5.0 (compatible; ${repoName}/${version}; +${repoUrl})`, + 'Accept': 'application/json', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', +}; + +const HEADERS = { + json: { ...DEFAULT_HEADERS, 'Content-Type': 'application/json' }, + multipart: { ...DEFAULT_HEADERS, 'Content-Type': 'multipart/form-data' }, +}; + +const SERVICE_HEADERS = { + sniffcat: { ...HEADERS.json, 'X-Secret-Token': SNIFFCAT_API_KEY }, + abuseipdb: { ...HEADERS.json, Key: ABUSEIPDB_API_KEY }, + sefinek: { ...DEFAULT_HEADERS, 'X-API-Key': SEFIN_API_SECRET_TOKEN }, + cloudflare: { ...HEADERS.json, Authorization: `Bearer ${CLOUDFLARE_API_KEY}` }, +}; + +const BASE_URLS = { + sniffcat: 'https://api.sniffcat.com/api/v1', + abuseipdb: 'https://api.abuseipdb.com/api/v2', +}; + +const resolveServiceConfig = str => { + const lower = str.toLowerCase(); + for (const [key, baseURL] of Object.entries(BASE_URLS)) { + if (lower.includes(key)) return { baseURL, headers: SERVICE_HEADERS[key], serviceKey: key }; + } + return null; +}; + +const serviceConfig = resolveServiceConfig(repoName); +if (!serviceConfig) { + getLogger().log(`No matching baseURL found for repoName '${repoName}', expected one of: ${Object.keys(BASE_URLS).join(', ')}`, 3, true); + process.exit(1); +} + +if (SERVER_ID === 'development' && EXTENDED_LOGS && process.argv.length <= 2) { + getLogger().log(`Base URL: ${serviceConfig.baseURL}`); + getLogger().log(JSON.stringify(serviceConfig.headers)); +} + +// Resolve headers for bulk (multipart + token) +const resolveBulkHeaders = serviceKey => { + if (!serviceKey || !SERVICE_HEADERS[serviceKey]) return HEADERS.multipart; + return { ...SERVICE_HEADERS[serviceKey], 'Content-Type': 'multipart/form-data' }; +}; + +// Axios instances +const axiosGeneric = axios.create({ timeout: 20000, headers: DEFAULT_HEADERS }); +const axiosService = axios.create({ baseURL: serviceConfig.baseURL, timeout: 30000, headers: serviceConfig.headers }); +const axiosBulk = axios.create({ baseURL: serviceConfig.baseURL, timeout: 55000, headers: resolveBulkHeaders(serviceConfig.serviceKey) }); +const axiosSefinek = axios.create({ timeout: 35000, headers: SERVICE_HEADERS.sefinek }); +const axiosWebhook = axios.create({ timeout: 15000, headers: DEFAULT_HEADERS }); +const axiosCloudflare = axios.create({ baseURL: 'https://api.cloudflare.com/client/v4', timeout: 30000, headers: SERVICE_HEADERS.cloudflare }); + +// Retry interceptor +const MAX_RETRIES = 3; +const RETRY_DELAY = count => count * 7000; +const RETRYABLE_CODES = new Set(['ECONNABORTED', 'ENOTFOUND', 'ETIMEDOUT']); +const isRetryable = err => + RETRYABLE_CODES.has(err.code) || + err.response?.status >= 500; +const hasStream = config => config.data && typeof config.data.pipe === 'function'; + +const applyRetry = instance => { + instance.interceptors.response.use(null, async err => { + const config = err.config; + if (!config) throw err; + + config.__retryCount ??= 0; + if (config.__retryCount >= MAX_RETRIES || !isRetryable(err) || hasStream(config)) throw err; + + config.__retryCount++; + const status = err.response?.status + ? `Status ${err.response.status}` + : err.code || err.message || 'Unknown error'; + getLogger().log(`${status} - retry #${config.__retryCount} for ${config.url}`, 2); + + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY(config.__retryCount))); + return instance(config); + }); +}; + +[axiosGeneric, axiosService, axiosBulk, axiosSefinek, axiosWebhook, axiosCloudflare].forEach(applyRetry); + +module.exports = Object.freeze({ + axiosGeneric, // Generic client without baseURL + axiosService, // Main client for SniffCat, AbuseIPDB + axiosBulk, // Bulk client with multipart/form-data (CSV upload) for SniffCat and AbuseIPDB + axiosSefinek, // Client for Sefinek API + axiosWebhook, // Client for Discord webhooks + axiosCloudflare, // Client for Cloudflare API + HEADERS, // Predefined headers for all services +}); \ No newline at end of file diff --git a/scripts/services/bulk.js b/scripts/services/bulk.js new file mode 100644 index 0000000..f7e3922 --- /dev/null +++ b/scripts/services/bulk.js @@ -0,0 +1,187 @@ +const FormData = require('form-data'); +const { parse } = require('csv-parse/sync'); +const { stringify } = require('csv-stringify/sync'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { axiosBulk } = require('../services/axios.js'); +const { saveReportedIPs, markIPAsReported } = require('../services/cache.js'); +const ABUSE_STATE = require('./state.js'); +const { queueWrite, atomicWriteFile } = require('./writeQueue.js'); +const logger = require('../logger.js'); + +const BULK_REPORT_BUFFER = new Map(); +const BUFFER_FILE = path.join(__dirname, '..', '..', 'tmp', 'bulk-report-buffer.csv'); +const OFFLINE_MODE = false; + +const ensureDirectoryExists = async filePath => { + try { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } +}; + +const saveBufferToFile = () => queueWrite(BUFFER_FILE, async () => { + if (!BULK_REPORT_BUFFER.size) return; + + const records = []; + for (const [ip, entry] of BULK_REPORT_BUFFER.entries()) { + records.push([ip, entry.categories, new Date(entry.timestamp).toISOString(), entry.comment]); + } + + try { + const output = stringify(records, { header: true, columns: ['IP', 'Categories', 'ReportDate', 'Comment'], quoted: true }); + await ensureDirectoryExists(BUFFER_FILE); + await atomicWriteFile(BUFFER_FILE, output); + } catch (err) { + logger.error(`Failed to write buffer file: ${err.message}`, { ping: true }); + } +}); + +const loadBufferFromFile = async () => { + try { + await fs.access(BUFFER_FILE); + + const fileContent = await fs.readFile(BUFFER_FILE, 'utf-8'); + const records = parse(fileContent, { columns: false, from_line: 2, skip_empty_lines: true, trim: true }); + + for (const record of records) { + const [ip, categories, timestamp, comment] = record; + if (!ip || !timestamp) continue; + BULK_REPORT_BUFFER.set(ip, { categories, timestamp: new Date(timestamp).getTime(), comment }); + } + } catch (err) { + if (err.code !== 'ENOENT') logger.error(`Failed to parse/load buffer file: ${err.message}`); + } +}; + +const sendBulkReport = async () => { + if (!BULK_REPORT_BUFFER.size) return; + + logger.info(`Starting bulk report upload (${BULK_REPORT_BUFFER.size} IPs)...`); + + const records = Array.from(BULK_REPORT_BUFFER.entries(), ([ip, entry]) => [ + ip, + entry.categories, + new Date(entry.timestamp ?? Date.now()).toISOString(), + entry.comment ? (entry.comment.length > 1024 ? entry.comment.substring(0, 1021) + '...' : entry.comment) : '', + ]); + + const sendChunk = async (chunk, index = 0, total = 1) => { + const payload = stringify(chunk, { + header: true, + columns: ['IP', 'Categories', 'ReportDate', 'Comment'], + quoted: true, + }); + + const lines = payload.split('\n').length; + logger.success(`${total > 1 ? `Chunk ${index + 1}/${total}: ` : ''}Generated CSV with ${chunk.length} records, ${lines} lines`); + + if (OFFLINE_MODE) { + if (lines > 10000) { + const error = new Error('Line limit exceeded'); + error.response = { + status: 422, + data: { errors: [{ detail: `File uploaded has ${lines} lines which exceeds the line limit of 10,000 lines.`, status: 422 }] }, + }; + throw error; + } + + const saved = chunk.length; + logger.success(`${total > 1 ? `Chunk ${index + 1}/${total}: ` : 'Sent bulk report: '}${saved} accepted, 0 rejected [OFFLINE]`, { discord: true }); + return; + } + + if (lines > 10000) { + const error = new Error('Line limit exceeded'); + error.response = { + status: 422, + data: { errors: [{ detail: `File uploaded has ${lines} lines which exceeds the line limit of 10,000 lines.`, status: 422 }] }, + }; + throw error; + } + + const form = new FormData(); + form.append('csv', Buffer.from(payload), { + filename: total > 1 ? `report-chunk-${index + 1}.csv` : 'report.csv', + contentType: 'text/csv', + }); + + const { data } = await axiosBulk.post('/bulk-report', form, { headers: { ...form.getHeaders() } }); + const saved = data?.data?.savedReports ?? 0; + const failed = data?.data?.invalidReports?.length ?? 0; + + logger.success(`${total > 1 ? `Chunk ${index + 1}/${total}: ` : 'Sent bulk report: '}${saved} accepted, ${failed} rejected`, { discord: true }); + + if (failed > 0) { + const prefix = total > 1 ? `Chunk ${index + 1}: ` : ''; + data.data.invalidReports.forEach(fail => { + logger.warn(`${prefix}Rejected [Row ${fail.rowNumber}] ${fail.input} -> ${fail.error}`); + }); + } + }; + + const isLineLimitError = err => + err.response?.status === 422 && + err.response?.data?.errors?.some(e => e.detail.includes('exceeds the line limit')); + + let success = false; + + try { + await sendChunk(records); + success = true; + } catch (err) { + if (isLineLimitError(err)) { + const chunks = []; + const estimatedLinesPerRecord = 10; + const chunkSize = Math.max(1, Math.floor(9000 / estimatedLinesPerRecord)); + + for (let i = 0; i < records.length; i += chunkSize) { + chunks.push(records.slice(i, i + chunkSize)); + } + + logger.info(`File too large. Splitting ${records.length} records into ${chunks.length} chunks (~${chunkSize} records each)`, { discord: true }); + + let allChunksSuccessful = true; + for (let i = 0; i < chunks.length; i++) { + try { + await sendChunk(chunks[i], i, chunks.length); + } catch (chunkErr) { + logger.error(`Chunk ${i + 1} failed: ${chunkErr.response?.data?.errors?.[0]?.detail || chunkErr.message}`); + allChunksSuccessful = false; + } + } + success = allChunksSuccessful; + } else { + const errorMsg = err.response?.data?.errors + ? err.response.data.errors.map(e => e.detail).join(', ') + : err.response?.data ? JSON.stringify(err.response.data) : err.stack; + + logger.error(errorMsg); + } + } + + if (success) { + for (const ip of BULK_REPORT_BUFFER.keys()) markIPAsReported(ip); + await saveReportedIPs(); + BULK_REPORT_BUFFER.clear(); + + try { + await fs.unlink(BUFFER_FILE); + } catch (err) { + // Ignore file deletion errors - file may not exist or already deleted + if (err.code !== 'ENOENT') logger.warn(`Failed to delete buffer file: ${err.message}`); + } + + ABUSE_STATE.sentBulk = true; + } else { + logger.error('Bulk upload failed, keeping buffer file for retry', { ping: true }); + } +}; + +module.exports = Object.freeze({ + saveBufferToFile, + loadBufferFromFile, + sendBulkReport, + BULK_REPORT_BUFFER, +}); diff --git a/scripts/services/cache.js b/scripts/services/cache.js new file mode 100644 index 0000000..6d0b7b0 --- /dev/null +++ b/scripts/services/cache.js @@ -0,0 +1,80 @@ +const { dirname } = require('node:path'); +const fs = require('node:fs/promises'); +const resolvePath = require('../pathResolver.js'); +const { CACHE_FILE, IP_REPORT_COOLDOWN } = require('../../config.js').MAIN; +const { queueWrite, atomicWriteFile } = require('./writeQueue.js'); +const logger = require('../logger.js'); + +const reportedIPs = new Map(); +const RESOLVED_CACHE_FILE = resolvePath(CACHE_FILE); + +const ensureCacheDir = async () => { + const dir = dirname(RESOLVED_CACHE_FILE); + + try { + await fs.access(dir); + } catch (err) { + if (err.code === 'ENOENT') { + await fs.mkdir(dir, { recursive: true }); + logger.success(`Created cache directory: ${dir}`); + } else { + logger.error(`Failed to access cache directory: ${err.stack}`); + } + } +}; + +const loadReportedIPs = async () => { + if (!RESOLVED_CACHE_FILE) return; + + await ensureCacheDir(); + + try { + const fileContent = await fs.readFile(RESOLVED_CACHE_FILE, 'utf8'); + fileContent + .split('\n') + .filter(Boolean) + .forEach(line => { + const [ip, time] = line.trim().split(/\s+/); + if (ip && !isNaN(time)) reportedIPs.set(ip, Number(time)); + }); + } catch (err) { + if (err.code === 'ENOENT') { + logger.info(`${RESOLVED_CACHE_FILE} does not exist. No data to load.`); + } else { + logger.error(`Failed to load cache file: ${err.stack}`); + } + } +}; + +const saveReportedIPs = () => { + if (!RESOLVED_CACHE_FILE) return Promise.resolve(); + return queueWrite(RESOLVED_CACHE_FILE, async () => { + await ensureCacheDir(); + try { + const data = Array.from(reportedIPs) + .map(([ip, time]) => `${ip} ${time}`) + .join('\n'); + await atomicWriteFile(RESOLVED_CACHE_FILE, data); + } catch (err) { + logger.error(`Failed to save cache file: ${err.stack}`); + } + }); +}; + +const IP_REPORT_COOLDOWN_SECONDS = IP_REPORT_COOLDOWN / 1000; + +const isIPReportedRecently = ip => { + const reportedTime = reportedIPs.get(ip); + if (!reportedTime) return false; + return (Math.floor(Date.now() / 1000) - reportedTime < IP_REPORT_COOLDOWN_SECONDS); +}; + +const markIPAsReported = ip => reportedIPs.set(ip, Math.floor(Date.now() / 1000)); + +module.exports = Object.freeze({ + reportedIPs, + loadReportedIPs, + saveReportedIPs, + isIPReportedRecently, + markIPAsReported, +}); diff --git a/scripts/services/cloudflare/csv.js b/scripts/services/cloudflare/csv.js new file mode 100644 index 0000000..66045ca --- /dev/null +++ b/scripts/services/cloudflare/csv.js @@ -0,0 +1,144 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { existsSync } = require('node:fs'); +const { parse } = require('csv-parse/sync'); +const { stringify } = require('csv-stringify/sync'); +const logger = require('../../logger.js'); + +const CSV_FILE = path.join(__dirname, '..', '..', '..', 'tmp', 'reported_ips.csv'); +const CSV_COLUMNS = ['Timestamp', 'CF RayID', 'IP', 'Country', 'Hostname', 'Endpoint', 'User-Agent', 'Action taken', 'Status', 'Sefinek API']; +const MAX_CSV_SIZE = 1024 * 1024; + +const CSV_STRINGIFY_OPTS = { + header: true, + columns: CSV_COLUMNS, + record_delimiter: '\n', + quoted: true, + quoted_empty: true, + quoted_string: true, +}; + +const CSV_PARSE_OPTS = { + columns: true, + skip_empty_lines: true, + trim: false, + skip_records_with_empty_values: false, +}; + +const ensureCSVExists = async () => { + try { + if (existsSync(CSV_FILE)) return; + await fs.mkdir(path.dirname(CSV_FILE), { recursive: true }); + await fs.writeFile(CSV_FILE, stringify([], CSV_STRINGIFY_OPTS)); + logger.success(`Created missing CSV file: ${CSV_FILE}`); + } catch (err) { + logger.error(`Failed to ensure CSV exists: ${err.stack}`); + throw err; + } +}; + +const checkCSVSize = async () => { + try { + if (!existsSync(CSV_FILE)) return; + const stats = await fs.stat(CSV_FILE); + + if (stats.size > MAX_CSV_SIZE) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = `${CSV_FILE}.${timestamp}.bkp`; + + await fs.copyFile(CSV_FILE, backupPath); + await fs.writeFile(CSV_FILE, stringify([], CSV_STRINGIFY_OPTS)); + logger.success(`CSV size limit exceeded (${(MAX_CSV_SIZE / (1024 * 1024)).toFixed(2)} MB). Backup saved as "${path.basename(backupPath)}", original file cleared.`); + } + } catch (err) { + logger.error(`Failed to check or clear CSV size: ${err.stack}`); + } +}; + +const logToCSV = async (event, status = 'N/A', sefinekAPI = false) => { + try { + await ensureCSVExists(); + await checkCSVSize(); + + const { + rayName, clientIP, clientCountryName, + clientRequestHTTPHost, clientRequestPath, + userAgent, action, + } = event; + + const row = { + 'Timestamp': new Date().toISOString(), + 'CF RayID': rayName, + 'IP': clientIP, + 'Country': clientCountryName, + 'Hostname': clientRequestHTTPHost, + 'Endpoint': clientRequestPath, + 'User-Agent': userAgent, + 'Action taken': action.toUpperCase(), + 'Status': status, + 'Sefinek API': String(sefinekAPI), + }; + + const line = stringify([row], { ...CSV_STRINGIFY_OPTS, header: false }); + await fs.appendFile(CSV_FILE, line); + } catch (err) { + logger.error(`Failed to log event to CSV: ${err.stack}`); + } +}; + +const readReportedIPs = async () => { + if (!existsSync(CSV_FILE)) return []; + + try { + const content = await fs.readFile(CSV_FILE, 'utf-8'); + const records = parse(content, CSV_PARSE_OPTS); + + return records.map(row => ({ + timestamp: Date.parse(row['Timestamp']), + rayId: row['CF RayID'], + ip: row['IP'], + country: row['Country'], + hostname: row['Hostname'], + endpoint: row['Endpoint'], + userAgent: row['User-Agent'], + action: row['Action taken'], + status: row['Status'], + sefinekAPI: row['Sefinek API'] === 'true', + })); + } catch (err) { + logger.error(`Failed to parse or map CSV content: ${err.stack}`); + return []; + } +}; + +const batchUpdateSefinekAPIInCSV = async (rayIds = []) => { + if (!existsSync(CSV_FILE) || !rayIds.length) return; + + try { + const content = await fs.readFile(CSV_FILE, 'utf-8'); + const records = parse(content, CSV_PARSE_OPTS); + + const raySet = new Set(rayIds); + let updated = false; + + for (const row of records) { + if (raySet.has(row['CF RayID']) && row['Sefinek API'] !== 'true') { + row['Sefinek API'] = 'true'; + updated = true; + } + } + + if (updated) { + const output = stringify(records, CSV_STRINGIFY_OPTS); + await fs.writeFile(CSV_FILE, output); + } + } catch (err) { + logger.error(`Batch CSV update failed: ${err.stack}`); + } +}; + +module.exports = Object.freeze({ + logToCSV, + readReportedIPs, + batchUpdateSefinekAPIInCSV, +}); diff --git a/scripts/services/cloudflare/generateFirewallQuery.js b/scripts/services/cloudflare/generateFirewallQuery.js new file mode 100644 index 0000000..5e94eae --- /dev/null +++ b/scripts/services/cloudflare/generateFirewallQuery.js @@ -0,0 +1,55 @@ +const { MAIN } = require('../../../config.js'); + +module.exports = zoneTag => { + if (!zoneTag) throw new Error('zoneTag is null or undefined'); + + const variables = { + zoneTag, + filter: { + datetime_geq: new Date(Date.now() - MAIN.CLOUDFLARE_TIME_RANGE || 12 * 60 * 60 * 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' }, + ], + }, + }; + + return { query: ` +query ListFirewallEvents($zoneTag: string, $filter: FirewallEventsAdaptiveFilter_InputObject) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + firewallEventsAdaptive( + filter: $filter, + limit: ${MAIN.CLOUDFLARE_EVENTS_LIMIT || 3000}, + orderBy: [datetime_DESC] + ) { + action + clientASNDescription + clientAsn + clientCountryName + clientIP + clientRequestHTTPHost + clientRequestHTTPMethodName + clientRequestHTTPProtocol + clientRequestPath + clientRequestQuery + datetime + rayName + ruleId + source + userAgent + } + } + } +}`, variables }; +}; \ No newline at end of file diff --git a/scripts/services/cloudflare/generateL7Report.js b/scripts/services/cloudflare/generateL7Report.js new file mode 100644 index 0000000..a8d22ff --- /dev/null +++ b/scripts/services/cloudflare/generateL7Report.js @@ -0,0 +1,64 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const PAYLOAD = require('./generateFirewallQuery.js'); +const { axiosCloudflare } = require('../axios.js'); +const logger = require('../../logger.js'); +const { MAIN } = require('../../../config.js'); + +const ensureFile = filePath => { + if (fs.existsSync(filePath)) return; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, ''); +}; + +const fetchCloudflareEvents = async () => { + const rawZoneIds = MAIN.CLOUDFLARE_ZONE_IDS || MAIN.CLOUDFLARE_ZONE_ID; + const zoneIds = Array.isArray(rawZoneIds) ? rawZoneIds : [rawZoneIds]; + const allEvents = []; + + for (const zoneId of zoneIds) { + try { + const { data, status } = await axiosCloudflare.post('/graphql', PAYLOAD(zoneId)); + + const events = data?.data?.viewer?.zones?.[0]?.firewallEventsAdaptive; + if (!events) throw new Error(`Failed to retrieve data from Cloudflare (status ${status}): ${JSON.stringify(data?.errors)}`); + + const l7ddosEvents = events.filter(e => e.source === 'l7ddos'); + logger.success(`Zone ${zoneId}: ${events.length} events fetched (${l7ddosEvents.length} L7 DDoS)`); + allEvents.push(...l7ddosEvents); + } catch (err) { + logger.error(err.response?.data ? `${err.response.status} HTTP ERROR for zone ${zoneId}: ${JSON.stringify(err.response.data, null, 2)}` : `Unknown error for zone ${zoneId}: ${err.message}`); + } + } + + return allEvents; +}; + +const saveToCSV = (events, filePath = 'report.csv') => { + const map = new Map(); + for (const { clientIP, datetime } of events) { + if (!map.has(clientIP)) map.set(clientIP, `${clientIP},4,${new Date(datetime).toISOString()},Detected L7 DDoS via Cloudflare`); + } + + ensureFile(filePath); + if (!fs.statSync(filePath).size) fs.appendFileSync(filePath, 'IP,Categories,ReportDate,Comment\n'); + + fs.appendFileSync(filePath, [...map.values()].join('\n') + '\n'); +}; + +const saveToTXT = (events, filePath = 'report.txt') => { + const uniqueIPs = new Set(events.map(e => e.clientIP)); + ensureFile(filePath); + fs.appendFileSync(filePath, [...uniqueIPs].join('\n') + '\n'); +}; + +(async () => { + const events = await fetchCloudflareEvents(); + if (events.length) { + saveToCSV(events); + saveToTXT(events); + logger.success(`Saved ${events.length} L7 DDoS events to CSV and TXT`); + } else { + logger.info('No L7 DDoS events found'); + } +})(); diff --git a/scripts/services/cloudflare/getFilterRules.js b/scripts/services/cloudflare/getFilterRules.js new file mode 100644 index 0000000..87510a4 --- /dev/null +++ b/scripts/services/cloudflare/getFilterRules.js @@ -0,0 +1,22 @@ +const { axiosGeneric } = require('../axios.js'); +const logger = require('../../logger.js'); + +module.exports = async () => { + try { + const res = await axiosGeneric.get('https://api.sefinek.net/api/v2/filter-rules'); + const data = res.data; + if (!data.success || typeof data !== 'object') { + throw new Error(`Sefinek API error: Invalid response (success=${data.success})`); + } + + const { userAgents, domains, endpoints, imgExtensions, lastUpdate } = data; + if (!Array.isArray(userAgents) || !Array.isArray(domains) || !Array.isArray(endpoints) || !Array.isArray(imgExtensions)) { + throw new Error('Sefinek API error: Response is missing expected arrays'); + } + + logger.success(`Filter rules loaded from api.sefinek.net, last update: ${lastUpdate}`); + return { userAgents, domains, endpoints, imgExtensions, lastUpdate }; + } catch (err) { + throw new Error(`Failed to fetch filter rules: ${err.message}`, { cause: err }); + } +}; diff --git a/scripts/services/cloudflare/reportToSefinek.js b/scripts/services/cloudflare/reportToSefinek.js new file mode 100644 index 0000000..2a0a921 --- /dev/null +++ b/scripts/services/cloudflare/reportToSefinek.js @@ -0,0 +1,56 @@ +const FormData = require('form-data'); +const logger = require('../../logger.js'); +const { readReportedIPs, batchUpdateSefinekAPIInCSV } = require('./csv.js'); +const { axiosSefinek } = require('../axios.js'); +const { getServerIPs } = require('../ipFetcher.js'); + +module.exports = async () => { + const serverIPs = getServerIPs(); + const reportedIPs = (await readReportedIPs() || []).filter(x => + ['REPORTED', 'READY_FOR_BULK_REPORT', 'RL_BULK_REPORT'].includes(x.status) && + !serverIPs.includes(x.ip) && + !x.sefinekAPI + ); + if (!reportedIPs.length) return logger.info('Sefinek API: No data to report'); + + const seenIPs = new Set(); + const uniqueLogs = reportedIPs.filter(ip => { + if (seenIPs.has(ip.ip)) return false; + seenIPs.add(ip.ip); + return true; + }); + + if (!uniqueLogs.length) return logger.info(`Sefinek API: No unique IPs to send (reportedIPs = ${reportedIPs.length}; uniqueLogs = ${uniqueLogs.length})`); + + try { + const payload = uniqueLogs.map(ip => ({ + rayId: ip.rayId, + ip: ip.ip, + endpoint: ip.endpoint, + userAgent: ip.userAgent, + action: ip.action, + country: ip.country, + timestamp: ip.timestamp, + })); + + const base64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'); + const form = new FormData(); + form.append('file', base64, { + filename: 'reports.dat', + contentType: 'application/octet-stream', + }); + + const res = await axiosSefinek.post('https://api.sefinek.net/api/v2/cloudflare-waf-abuseipdb', form, { headers: { ...form.getHeaders() } }); + // const res = await axiosSefinek.post('http://127.0.0.1:4010/api/v2/cloudflare-waf-abuseipdb', form, { headers: { ...form.getHeaders() } }); + if (res.data.success) { + logger.success(`Sefinek API (status: ${res.status}): Successfully sent ${uniqueLogs.length} logs`); + await batchUpdateSefinekAPIInCSV(uniqueLogs.map(x => x.rayId)); + } else { + logger.warn(`Sefinek API (status: ${res.status}): ${res.data.message || 'Something went wrong'}`); + } + } catch (err) { + if (err.response?.data?.message?.includes('No valid or unique')) return; + const msg = err.response?.data?.message ?? err.message; + logger.error(`Sefinek API (status: ${err.response?.status ?? 'unknown'}): Failed to send logs! Message: ${typeof msg === 'object' ? JSON.stringify(msg) : msg}`, { ping: true }); + } +}; diff --git a/scripts/services/ipFetcher.js b/scripts/services/ipFetcher.js new file mode 100644 index 0000000..ab90097 --- /dev/null +++ b/scripts/services/ipFetcher.js @@ -0,0 +1,84 @@ +const { networkInterfaces } = require('node:os'); +const https = require('node:https'); +const { CronJob } = require('cron'); +const { axiosSefinek } = require('./axios.js'); +const isSpecialPurposeIP = require('../isSpecialPurposeIP.js'); +const logger = require('../logger.js'); +const { IP_ASSIGNMENT, IP_REFRESH_SCHEDULE, IPv6_SUPPORT } = require('../../config.js').MAIN; + +const ipAddresses = new Set(); +let ipv6ErrorCount = 0, ipv6ErrorLogged = false; +let isRefreshing = false; + +const fetchLocalIPs = () => { + for (const ifaceList of Object.values(networkInterfaces())) { + for (const iface of ifaceList) { + const addr = iface?.address; + if (addr && !iface.internal && !isSpecialPurposeIP(addr).is) ipAddresses.add(addr); + } + } +}; + +const fetchIPAddress = async (family, attempt = 1) => { + if (family === 0) return fetchLocalIPs(); + if (family === 6 && (!IPv6_SUPPORT || ipv6ErrorLogged)) return; + + try { + const { data } = await axiosSefinek.get('https://api.sefinek.net/api/v2/ip', { + httpsAgent: new https.Agent({ family }), + }); + + if (data?.success && data?.message) { + ipAddresses.add(data.message); + + if (family === 6 && ipv6ErrorCount > 0) { + logger.success(`IPv6 is now working. It succeeded after ${ipv6ErrorCount} failed attempts.`); + ipv6ErrorCount = 0; + ipv6ErrorLogged = false; + } + } else { + logger.warn(`Unexpected API response: success=${data?.success}, message=${data?.message}`); + } + } catch (err) { + logger.error(`Failed to fetch IPv${family} address (attempt ${attempt}): ${err.message}`); + + if (family === 6) { + ipv6ErrorCount++; + + if (ipv6ErrorCount >= 6 && !ipv6ErrorLogged) { + ipv6ErrorLogged = true; + logger.warn('IPv6 address could not be retrieved after multiple attempts. Disabling further checks.'); + } else if (attempt < 6) { + await new Promise(resolve => setTimeout(resolve, 4000)); + await fetchIPAddress(6, attempt + 1); + } + } + } +}; + +const refreshServerIPs = async () => { + if (isRefreshing) { + logger.info('IP refresh already in progress, skipping...'); + return; + } + + isRefreshing = true; + logger.info('Trying to fetch your IPv4 and IPv6 address from api.sefinek.net...'); + + try { + await Promise.all([fetchIPAddress(0), fetchIPAddress(4), fetchIPAddress(6)]); + } catch (err) { + logger.error(`Failed to refresh IP addresses: ${err.message}`); + } finally { + isRefreshing = false; + } +}; + +(async () => { + if (IP_ASSIGNMENT === 'dynamic') new CronJob(IP_REFRESH_SCHEDULE || '0 */6 * * *', refreshServerIPs, null, true); +})(); + +module.exports = Object.freeze({ + refreshServerIPs, + getServerIPs: () => [...ipAddresses], +}); diff --git a/scripts/services/restartApp.js b/scripts/services/restartApp.js new file mode 100644 index 0000000..66248ba --- /dev/null +++ b/scripts/services/restartApp.js @@ -0,0 +1,41 @@ +const { exec } = require('node:child_process'); +const ecosystem = require('../../ecosystem.config.js'); +const logger = require('../logger.js'); + +const EXEC_TIMEOUT = 60000; + +const executeCmd = cmd => new Promise((resolve, reject) => { + exec(cmd, { timeout: EXEC_TIMEOUT }, (err, stdout, stderr) => { + if (err) return reject(err); + if (stderr) logger.warn(`Warning while executing: ${cmd}\n${stderr}`); + + resolve(stdout.trim()); + }); +}); + +const CMD_1 = 'npm install --omit=dev'; +const APP_NAME = (() => { + if (!ecosystem.apps?.[0]?.name) throw new Error('Missing app name in ecosystem config'); + return ecosystem.apps[0].name; +})(); +const CMD_2 = `pm2 restart ${APP_NAME}`; + +module.exports = async () => { + try { + // 1 - npm dependencies + logger.info(`Running npm '${CMD_1}'...`); + const result1 = await executeCmd(CMD_1); + logger.info(result1, { discord: true }); + + // 2 - restart + if (process.env.pm_id !== undefined) { + logger.info(`Running '${CMD_2}'...`); + await executeCmd(CMD_2); + } else { + logger.info('Process is not managed by PM2! Exiting to apply updates. To start again, run: node . (one-time), pm2 start (24/7 with auto-restart).', { discord: true }); + process.exit(0); + } + } catch (err) { + logger.error(err.stack); + } +}; diff --git a/scripts/services/state.js b/scripts/services/state.js new file mode 100644 index 0000000..13d104f --- /dev/null +++ b/scripts/services/state.js @@ -0,0 +1 @@ +module.exports = { isLimited: false, isBuffering: false, sentBulk: false }; diff --git a/scripts/services/summaries.js b/scripts/services/summaries.js new file mode 100644 index 0000000..6511b24 --- /dev/null +++ b/scripts/services/summaries.js @@ -0,0 +1,99 @@ +const { CronJob } = require('cron'); +const fs = require('node:fs/promises'); +const logger = require('../logger.js'); +const resolvePath = require('../pathResolver.js'); +const { SERVER_ID, EXTENDED_LOGS, CACHE_FILE } = require('../../config.js').MAIN; + +const RESOLVED_CACHE_FILE = resolvePath(CACHE_FILE); + +const pad = n => n.toString().padStart(2, '0'); +const formatHourRange = h => `${pad(h)}:00-${pad(h)}:59`; +const pluralizeReport = n => (n === 1 ? 'report' : 'reports'); + +const summaryEmbed = async () => { + const now = new Date(); + const midnightLocal = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(midnightLocal.getTime() - 86400000); + const yesterdayString = `${yesterday.getFullYear()}-${pad(yesterday.getMonth() + 1)}-${pad(yesterday.getDate())}`; + const baseTs = Math.floor(Date.UTC(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate()) / 1000); + + if (EXTENDED_LOGS) { + logger.info(`Daily summary ยป Using base timestamp: ${baseTs} (${new Date(baseTs * 1000).toISOString()}) for ${yesterdayString}`); + } + + let data; + if (SERVER_ID === 'development') { + logger.info('Daily summary ยป Using test data instead of reading cache file [DEV]'); + const generateRandomIp = () => Array(4).fill(0).map(() => Math.floor(Math.random() * 256)).join('.'); + const generateTimestamps = () => baseTs + Math.floor(Math.random() * 86400); + const entries = Array.from({ length: Math.floor(Math.random() * 1001) + 4000 }, () => `${generateRandomIp()} ${generateTimestamps()}`); + data = entries.join('\n'); + } else { + try { + await fs.access(RESOLVED_CACHE_FILE); + data = (await fs.readFile(RESOLVED_CACHE_FILE, 'utf8')).trim(); + if (!data) return logger.warn(`Daily summary ยป Cache file exists but is empty: ${RESOLVED_CACHE_FILE}`); + } catch (err) { + return logger.warn(`Daily summary ยป Failed to access/read cache file: ${err.message}`); + } + } + + try { + const hourlySummary = {}; + const seen = new Set(); + + for (const line of data.split('\n')) { + const [ip, tsStr] = line.split(' '); + const ts = parseInt(tsStr, 10); + if (!ip || isNaN(ts)) continue; + + const key = `${ip}_${ts}`; + if (seen.has(key)) continue; + seen.add(key); + + const date = new Date(ts * 1000); + const dateStr = date.toISOString().split('T')[0]; + if (dateStr !== yesterdayString) continue; + + const hour = date.getUTCHours(); + hourlySummary[hour] = (hourlySummary[hour] || 0) + 1; + } + + if (!Object.keys(hourlySummary).length) { + return logger.warn(`Daily summary ยป No reports found for ${yesterdayString}`); + } + + const sortedChrono = Object.entries(hourlySummary) + .map(([h, c]) => [parseInt(h), c]) + .sort((a, b) => a[0] - b[0]); + + const top3Sorted = [...Object.entries(hourlySummary)] + .map(([h, c]) => [parseInt(h), c]) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3); + + const top3 = top3Sorted.map(([h]) => h); + const total = sortedChrono.reduce((sum, [, c]) => sum + c, 0); + const summaryStr = sortedChrono + .map(([h, c]) => `${formatHourRange(h)} โ†’ ${c} ${pluralizeReport(c)}${top3.includes(h) ? ' ๐Ÿ”ฅ' : ''}`) + .join('\n'); + logger.info(`Midnight. Summary of IP address reports (${total}) from ${yesterdayString}:\n${summaryStr}`); + + const peakStr = top3Sorted + .map(([h, c]) => `${formatHourRange(h)} โ†’ ${c} ${pluralizeReport(c)}`) + .join('\n'); + logger.info(`Top 3 peaks:\n${peakStr}`); + + await logger.webhook( + `Midnight. Summary of IP address reports from yesterday.\nGood night to you, sleep well! ๐Ÿ˜ด\n### ${total} ${pluralizeReport(total)} from ${yesterdayString}\n\`\`\`${summaryStr}\`\`\`\n### Top 3 peaks\n\`\`\`${peakStr}\`\`\``, + 0x00FF39 + ); + } catch (err) { + logger.error(`Daily summary ยป Unexpected error:\n${err.stack}`); + } +}; + +module.exports = async () => { + // await summaryEmbed(); + new CronJob('0 0 * * *', summaryEmbed, null, true); +}; \ No newline at end of file diff --git a/scripts/services/tailFile.js b/scripts/services/tailFile.js new file mode 100644 index 0000000..03f019d --- /dev/null +++ b/scripts/services/tailFile.js @@ -0,0 +1,60 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const TailFile = require('@logdna/tail-file'); +const split2 = require('split2'); +const logger = require('../logger.js'); + +/** + * Starts tailing a log file, calling onLine for each new line. + * If the file does not exist, waits for it to be created (directory must exist). + * + * @param {string} filePath - Path to the log file. + * @param {Function} onLine - Callback invoked for each new line (receives the line string). + * @param {Object} [options] + * @param {string} [options.label] - Label for log messages (e.g., 'DIONAEA', 'SURICATA'). + * @returns {Promise} Resolves with the TailFile instance once tailing starts, or null on error. + */ +module.exports = (filePath, onLine, options = {}) => { + const { label } = options; + const prefix = label ? `${label} -> ` : ''; + + const start = async () => { + const tail = new TailFile(filePath); + const onError = err => logger.error(`${prefix}${err.message || err}`); + + tail.on('tail_error', onError); + tail.on('error', onError); + await tail.start(); + + tail.pipe(split2()).on('data', onLine).on('error', onError); + return tail; + }; + + if (fs.existsSync(filePath)) return start(); + + const resolved = path.resolve(filePath); + const dir = path.dirname(resolved); + const filename = path.basename(resolved); + + if (!fs.existsSync(dir)) { + logger.error(`${prefix}Log file directory does not exist: ${dir}`); + return Promise.resolve(null); + } + + logger.warn(`${prefix}Log file does not exist yet: ${filePath}. Waiting for it to be created...`); + + return new Promise(resolve => { + const watcher = fs.watch(dir, (event, file) => { + if (file === filename && fs.existsSync(filePath)) { + watcher.close(); + logger.info(`${prefix}Log file detected: ${filePath}. Starting tail...`); + resolve(start()); + } + }); + + watcher.on('error', err => { + logger.error(`${prefix}Failed to watch directory ${dir}: ${err.message}`); + resolve(null); + }); + }); +}; diff --git a/scripts/services/updates.js b/scripts/services/updates.js new file mode 100644 index 0000000..4ac8f68 --- /dev/null +++ b/scripts/services/updates.js @@ -0,0 +1,91 @@ +const simpleGit = require('simple-git'); +const semver = require('semver'); +const { CronJob } = require('cron'); +const fs = require('node:fs'); +const path = require('node:path'); +const restartApp = require('./restartApp.js'); +const logger = require('../logger.js'); +const { SERVER_ID, AUTO_UPDATE_SCHEDULE, EXTENDED_LOGS } = require('../../config.js').MAIN; + +const git = simpleGit(); +const pkgPath = path.resolve(__dirname, '../../package.json'); +const LOCK_FILE = path.resolve(__dirname, '../../.git/index.lock'); +const STALE_LOCK_THRESHOLD = 60_000; + +const getLocalVersion = () => { + delete require.cache[require.resolve(pkgPath)]; + return require(pkgPath).version; +}; + +const cleanStaleLock = () => { + try { + const stat = fs.statSync(LOCK_FILE); + if (Date.now() - stat.mtimeMs > STALE_LOCK_THRESHOLD) { + fs.unlinkSync(LOCK_FILE); + logger.warn('Removed stale .git/index.lock file', { discord: true }); + } + } catch (err) { + if (err.code !== 'ENOENT') logger.error(`Failed to check/remove index.lock: ${err.message}`); + } +}; + +const pull = async () => { + try { + if (SERVER_ID !== 'development') { + if (EXTENDED_LOGS) logger.info('Resetting local repository to HEAD (--hard)...'); + await git.reset(['--hard']); + } + + logger.info('Pulling the repository and the required submodule...'); + + // Get submodule status before update + const submoduleBefore = await git.raw(['submodule', 'status']); + + // Pull main repo and update submodules + const pullResult = await git.pull(); + await git.submoduleUpdate(['--init', '--recursive', '--remote', '--merge']); + + // Get submodule status after update + const submoduleAfter = await git.raw(['submodule', 'status']); + const submoduleChanged = submoduleBefore !== submoduleAfter; + + const { changes, insertions, deletions } = pullResult.summary; + const mainRepoChanged = changes > 0 || insertions > 0 || deletions > 0; + const hasChanges = mainRepoChanged || submoduleChanged; + if (hasChanges) { + const parts = []; + if (mainRepoChanged) parts.push(`Main repo - Changes: ${changes}; Insertions: ${insertions}; Deletions: ${deletions};`); + if (submoduleChanged) parts.push('Submodule updated.'); + logger.success(`Updates pulled successfully! ${parts.join(' ')}`, { discord: true }); + } else { + logger.info('No new updates detected'); + } + + return hasChanges; + } catch (err) { + logger.error(err.stack); + return null; + } +}; + +const pullAndRestart = async () => { + try { + cleanStaleLock(); + + const oldVersion = getLocalVersion(); + const hasChanges = await pull(); + if (!hasChanges) return; + + const newVersion = getLocalVersion(); + if (semver.neq(newVersion, oldVersion)) { + logger.success(`Version changed: ${oldVersion} โ†’ ${newVersion}`, { discord: true }); + } + + await restartApp(); + } catch (err) { + logger.error(err.stack); + } +}; + +new CronJob(AUTO_UPDATE_SCHEDULE, pullAndRestart, null, true); +module.exports = pullAndRestart; diff --git a/scripts/services/version.js b/scripts/services/version.js new file mode 100644 index 0000000..42efbc8 --- /dev/null +++ b/scripts/services/version.js @@ -0,0 +1,22 @@ +const semver = require('semver'); +const { axiosGeneric } = require('./axios.js'); +const { version, repoSlug } = require('../repo.js'); +const logger = require('../logger.js'); + +const PACKAGE_JSON_URL = `https://raw.githubusercontent.com/${repoSlug}/main/package.json`; + +(async () => { + logger.info('Checking for new versions...'); + + try { + const { data: { version: latest } } = await axiosGeneric.get(PACKAGE_JSON_URL); + + if (semver.gt(latest, version)) { + logger.info(`A new version is available! v${version} โ†’ v${latest}. Please update.`, { discord: true }); + } else if (semver.gt(version, latest)) { + logger.info(`Local version v${version} is ahead of remote v${latest}`, { discord: true }); + } + } catch (err) { + logger.error(`Version check failed: ${err.stack}`); + } +})(); diff --git a/scripts/services/whitelist.js b/scripts/services/whitelist.js new file mode 100644 index 0000000..5cd985d --- /dev/null +++ b/scripts/services/whitelist.js @@ -0,0 +1,154 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const logger = require('../logger.js'); +const isSpecialPurposeIP = require('../isSpecialPurposeIP.js'); + +const filePath = path.resolve(__dirname, '..', '..', 'whitelist.txt'); +let whitelistedIPs = new Set(); +let whitelistedPorts = new Set(); // @chillcog Port Whitelist +let reloadTimer = null; + +const parse = content => { + // @chillcog Port Whitelist + const ipSet = new Set(); + const portSet = new Set(); + + const invalidIPs = [], skippedIPs = []; + const invalidPorts = [], skippedPorts = []; + + let section = 'ip'; + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + + const header = raw.trim().toLowerCase(); + if (header.includes('[port whitelist]') || (header.includes('port whitelist') && header.includes('['))) { + section = 'port'; + continue; + } + if (header.includes('[ip whitelist]') || (header.includes('ip whitelist') && header.includes('['))) { + section = 'ip'; + continue; + } + + const line = raw.split('#')[0].trim(); + if (!line) continue; + + if (section === 'port') { + const portStr = String(line).split(':').pop().split('/')[0].trim(); + const port = Number(portStr); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + invalidPorts.push(`${line} (line ${i + 1})`); + continue; + } + + if (portSet.has(port)) { + skippedPorts.push(`${line} (line ${i + 1})`); + continue; + } + + portSet.add(port); + continue; + } + + const { is, range } = isSpecialPurposeIP(line); + if (range === null) { + invalidIPs.push(`${line} (line ${i + 1})`); + continue; + } + + if (is) { + skippedIPs.push(`${line} (${range})`); + continue; + } + + ipSet.add(line); + } + + if (invalidIPs.length > 0) { + logger.warn(`Invalid IP address${invalidIPs.length > 1 ? 'es' : ''} in whitelist, skipping: ${invalidIPs.join(', ')}`); + } + + if (skippedIPs.length > 0) { + logger.warn(`Special-purpose IP${skippedIPs.length > 1 ? 's' : ''} detected in whitelist.txt, was this intentional? ${skippedIPs.join(', ')}`); + } + + if (invalidPorts.length > 0) { + logger.warn(`Invalid port${invalidPorts.length > 1 ? 's' : ''} in whitelist, skipping: ${invalidPorts.join(', ')}`); + } + + if (skippedPorts.length > 0) { + logger.warn(`Skipped duplicate port${skippedPorts.length > 1 ? 's' : ''} in whitelist.txt: ${skippedPorts.join(', ')}`); + } + + return { ipSet, portSet }; +}; + +const load = () => { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const { ipSet, portSet } = parse(content); + whitelistedIPs = ipSet; + whitelistedPorts = portSet; + + if (whitelistedIPs.size > 0) logger.info(`Loaded ${whitelistedIPs.size} whitelisted IP${whitelistedIPs.size > 1 ? 's' : ''} from ${filePath}`); + if (whitelistedPorts.size > 0) logger.info(`Loaded ${whitelistedPorts.size} whitelisted port${whitelistedPorts.size > 1 ? 's' : ''} from ${filePath}`); + } catch (err) { + logger.error(`Failed to load whitelist file: ${err.message}`); + } +}; + +const initWhitelist = () => { + if (!fs.existsSync(filePath)) { + fs.writeFileSync( + filePath, + [ + '# UFW AbuseIPDB Reporter - whitelist.txt', + '# Changes to this file are detected and applied automatically (no restart required)', + '', + '# ====== [IP WHITELIST] ======', + '# One IP address per line (IPv4 or IPv6)', + '# Lines starting with # are comments', + '#', + '# Examples:', + '# 79.186.0.0', + '# 2a01:11bf:4504:b10c:8a32:ffe7:510a:6d4e', + '', + '# ====== [PORT WHITELIST] ======', + '# One destination port per line. Applies to ALL IPs.', + '# Optional: you may write entries like "22/tcp"; the numeric port will be extracted.', + '# Added if you have certain services that trigger false positives and you want to exclude', + '# them from triggering reports regardless of source IP. Use with caution, as this will ignore', + '# all traffic to the specified port(s) and may cause you to miss legitimate reports if that port is attacked.', + '# Examples:', + '# 22', + '# 25565', + '# 1194/tcp', + ].join('\n') + '\n' + ); + logger.info(`Created default whitelist file: ${filePath}`); + } + // @chillcog end; + load(); + + const dir = path.dirname(filePath); + const filename = path.basename(filePath); + + try { + const watcher = fs.watch(dir, (event, file) => { + if (file !== filename) return; + clearTimeout(reloadTimer); + reloadTimer = setTimeout(load, 200); + }); + watcher.on('error', err => logger.error(`Whitelist watcher error: ${err.message}`)); + } catch (err) { + logger.error(`Failed to watch whitelist file: ${err.message}`); + } +}; + +module.exports = { + initWhitelist, + isWhitelisted: ip => whitelistedIPs.has(ip), + isPortWhitelisted: port => whitelistedPorts.has(port), +}; diff --git a/scripts/services/writeQueue.js b/scripts/services/writeQueue.js new file mode 100644 index 0000000..7051abc --- /dev/null +++ b/scripts/services/writeQueue.js @@ -0,0 +1,20 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const queues = new Map(); + +const queueWrite = (filePath, fn) => { + const key = path.resolve(filePath); + const prev = queues.get(key) || Promise.resolve(); + const next = prev.then(fn, fn); + queues.set(key, next); + return next; +}; + +const atomicWriteFile = async (filePath, data) => { + const tmp = `${filePath}.tmp`; + await fs.writeFile(tmp, data); + await fs.rename(tmp, filePath); +}; + +module.exports = { queueWrite, atomicWriteFile }; diff --git a/scripts/suricata/ignoredSignatures.js b/scripts/suricata/ignoredSignatures.js new file mode 100644 index 0000000..416b9bc --- /dev/null +++ b/scripts/suricata/ignoredSignatures.js @@ -0,0 +1,68 @@ +// ===================================================================================================================== +// Global Suricata Signature Blacklist +// Signatures listed here will NOT be reported. These are typically false positives from legitimate services. +// +// WARNING: Do not modify this file directly! +// To add custom signatures, use the IGNORED_SIGNATURES array in your config.js file. +// To disable this built-in list entirely, set USE_BUILT_IN_IGNORED_SIGNATURES to false. +// +// Last modified: 13.02.2026 +// ===================================================================================================================== + +const { USE_BUILT_IN_IGNORED_SIGNATURES, IGNORED_SIGNATURES } = require('../../config.js').MAIN; + +const GLOBAL_IGNORED = { + // ========================== + // Discord + // ========================== + 2035465: 'ET INFO Observed Discord Domain (discord.gg)', + 2060503: 'ET INFO Observed Discord Domain in TLS SNI', + + // ========================== + // Steam + // ========================== + 2028651: 'ET GAMES Steam Client HTTP Session', + + // ========================== + // Android Connectivity Check + // ========================== + 2036220: 'ET INFO Android Device Connectivity Check', + + // ========================== + // DDNS Services (Dynamic DNS) + // ========================== + 2043238: 'ET INFO Observed DDNS Domain', + 2013743: 'ET POLICY Observed DDNS Provider Domain', + + // ========================== + // Cloudflare Tunnel (argotunnel.com) + // ========================== + 2047122: 'ET INFO Cloudflare Argo Tunnel Domain', + + // ========================== + // Xiaomi + // ========================== + 2018918: 'ET INFO possible Xiaomi phone data leakage DNS', +}; + +const localIgnored = new Set(IGNORED_SIGNATURES || []); + +/** + * Check if signature should be ignored + * @param {number} sigId - Signature ID to check + * @returns {boolean} + */ +const isIgnored = sigId => (USE_BUILT_IN_IGNORED_SIGNATURES && sigId in GLOBAL_IGNORED) || localIgnored.has(sigId); + +/** + * Get description for ignored signature + * @param {number} sigId - Signature ID + * @returns {string|null} + */ +const getDescription = sigId => { + if (USE_BUILT_IN_IGNORED_SIGNATURES && sigId in GLOBAL_IGNORED) return GLOBAL_IGNORED[sigId]; + if (localIgnored.has(sigId)) return 'Local config'; + return null; +}; + +module.exports = { GLOBAL_IGNORED, isIgnored, getDescription }; diff --git a/scripts/validations/cloudflare.js b/scripts/validations/cloudflare.js new file mode 100644 index 0000000..98a4da0 --- /dev/null +++ b/scripts/validations/cloudflare.js @@ -0,0 +1,11 @@ +module.exports = [ + { key: 'RUN_ON_START', type: Boolean }, + { key: 'CLOUDFLARE_ZONE_IDS', type: Array, nonEmpty: true }, + { key: 'CLOUDFLARE_API_KEY', type: String, nonEmpty: true }, + { key: 'REPORT_SCHEDULE', type: String, nonEmpty: true }, + { key: 'MAX_URL_LENGTH', type: Number, min: 100 }, + { key: 'SUCCESS_COOLDOWN', type: Number, min: 0 }, + { key: 'CLOUDFLARE_TIME_RANGE', type: Number, min: 1 }, + { key: 'CLOUDFLARE_EVENTS_LIMIT', type: Number, min: 1, max: 10000 }, + { key: 'ALLOWED_SOURCES', type: Array, nonEmpty: true }, +]; diff --git a/scripts/validations/common.js b/scripts/validations/common.js new file mode 100644 index 0000000..4eca755 --- /dev/null +++ b/scripts/validations/common.js @@ -0,0 +1,17 @@ +exports.rules = [ + { key: 'IP_ASSIGNMENT', type: String, oneOf: ['static', 'dynamic'] }, + { key: 'IPv6_SUPPORT', type: Boolean }, + { key: 'EXTENDED_LOGS', type: Boolean }, + { key: 'CACHE_FILE', type: String, nonEmpty: true }, + + { key: 'AUTO_UPDATE_ENABLED', type: Boolean }, + { key: 'AUTO_UPDATE_SCHEDULE', type: String, required: false }, + + { key: 'DISCORD_WEBHOOK_ENABLED', type: Boolean }, + { key: 'DISCORD_WEBHOOK_URL', type: String, required: false }, +]; + +exports.services = { + sniffcat: { apiKey: 'SNIFFCAT_API_KEY', minCooldown: 20 * 60 * 1000 }, + abuseipdb: { apiKey: 'ABUSEIPDB_API_KEY', minCooldown: 15 * 60 * 1000 }, +}; diff --git a/scripts/validations/index.js b/scripts/validations/index.js new file mode 100644 index 0000000..8a3c22d --- /dev/null +++ b/scripts/validations/index.js @@ -0,0 +1,33 @@ +const { repoName } = require('../repo.js'); +const validateConfig = require('./validateConfig.js'); +const { rules: common, services } = require('./common.js'); + +const PROJECT_RULES = { + suricata: require('./suricata.js'), + ufw: require('./ufw.js'), + 't-pot': require('./tpot.js'), + cloudflare: require('./cloudflare.js'), +}; + +const resolveByRepoName = (map, name) => { + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(map)) { + if (lower.includes(key)) return value; + } + return null; +}; + +module.exports = config => { + const rules = [...common]; + + const serviceConf = resolveByRepoName(services, repoName); + if (serviceConf) { + rules.push({ key: serviceConf.apiKey, type: String, nonEmpty: true }); + rules.push({ key: 'IP_REPORT_COOLDOWN', type: Number, min: serviceConf.minCooldown }); + } + + const projectRules = resolveByRepoName(PROJECT_RULES, repoName); + if (projectRules) rules.push(...projectRules); + + validateConfig(config, rules); +}; diff --git a/scripts/validations/suricata.js b/scripts/validations/suricata.js new file mode 100644 index 0000000..25c4454 --- /dev/null +++ b/scripts/validations/suricata.js @@ -0,0 +1,7 @@ +module.exports = [ + { key: 'SURICATA_EVE_FILE', type: String, nonEmpty: true }, + { key: 'MIN_ALERT_SEVERITY', type: Number, min: 1, max: 3 }, + { key: 'ALLOWED_VERDICT_ACTIONS', type: Array, nonEmpty: true, oneOf: ['alert', 'pass', 'drop'], default: ['alert', 'drop'] }, + { key: 'USE_BUILT_IN_IGNORED_SIGNATURES', type: Boolean }, + { key: 'IGNORED_SIGNATURES', type: Array }, +]; diff --git a/scripts/validations/tpot.js b/scripts/validations/tpot.js new file mode 100644 index 0000000..e6f9993 --- /dev/null +++ b/scripts/validations/tpot.js @@ -0,0 +1,6 @@ +module.exports = [ + { key: 'COWRIE_LOG_FILE', type: String, nonEmpty: true }, + { key: 'DIONAEA_LOG_FILE', type: String, nonEmpty: true }, + { key: 'HONEYTRAP_LOG_FILE', type: String, nonEmpty: true }, + { key: 'LOG_IP_HISTORY_ENABLED', type: Boolean }, +]; diff --git a/scripts/validations/ufw.js b/scripts/validations/ufw.js new file mode 100644 index 0000000..2ad759c --- /dev/null +++ b/scripts/validations/ufw.js @@ -0,0 +1,3 @@ +module.exports = [ + { key: 'UFW_LOG_FILE', type: String, nonEmpty: true }, +]; diff --git a/scripts/validations/validateConfig.js b/scripts/validations/validateConfig.js new file mode 100644 index 0000000..04ef741 --- /dev/null +++ b/scripts/validations/validateConfig.js @@ -0,0 +1,170 @@ +const path = require('node:path'); +const logger = require('../logger.js'); + +const configPath = path.resolve(__dirname, '..', '..', 'config.js'); + +const TYPE_NAMES = new Map([ + [String, 'string'], + [Number, 'number'], + [Boolean, 'boolean'], + [Array, 'array'], +]); + +const RULE_SCHEMA = { + key: { type: 'string', required: true }, + type: { required: true }, + required: { type: 'boolean' }, + nonEmpty: { type: 'boolean', validFor: ['string', 'array'] }, + oneOf: { isArray: true }, + min: { type: 'number', validFor: ['number', 'array'] }, + max: { type: 'number', validFor: ['number'] }, + default: {}, +}; + +const VALID_RULE_KEYS = new Set(Object.keys(RULE_SCHEMA)); + +/** + * Validates a rule definition itself before using it to check config values. + * @param {Object} rule + * @param {number} index + * @returns {string[]} Array of error messages (empty if valid). + */ +const validateRule = (rule, index) => { + const errors = []; + + const prefix = `Rule #${index}`; + if (!rule || typeof rule !== 'object' || Array.isArray(rule)) return [`${prefix}: must be a plain object`]; + + const unknownKeys = Object.keys(rule).filter(k => !VALID_RULE_KEYS.has(k)); + if (unknownKeys.length > 0) errors.push(`${prefix} (${rule.key || '?'}): unknown properties: ${unknownKeys.join(', ')}`); + + if (typeof rule.key !== 'string' || rule.key.length === 0) { + return [...errors, `${prefix}: 'key' must be a non-empty string`]; + } + + const label = `${prefix} (${rule.key})`; + const typeName = TYPE_NAMES.get(rule.type); + if (!typeName) return [...errors, `${label}: 'type' must be String, Number, Boolean, or Array`]; + + // Validate rule property types and applicability + for (const [prop, schema] of Object.entries(RULE_SCHEMA)) { + if (rule[prop] === undefined || prop === 'key' || prop === 'type' || prop === 'default') continue; + + if (schema.type && typeof rule[prop] !== schema.type) { + errors.push(`${label}: '${prop}' must be ${schema.type}, got ${typeof rule[prop]}`); + } else if (schema.isArray && !Array.isArray(rule[prop])) { + errors.push(`${label}: '${prop}' must be an array`); + } + + if (schema.validFor && !schema.validFor.includes(typeName)) { + errors.push(`${label}: '${prop}' is only valid for ${schema.validFor.join(' and ')} types`); + } + } + + if (rule.min !== undefined && rule.max !== undefined && typeName === 'number' && rule.min > rule.max) { + errors.push(`${label}: 'min' (${rule.min}) cannot be greater than 'max' (${rule.max})`); + } + + // Validate default value + if ('default' in rule) { + const def = rule.default; + const isCorrectType = typeName === 'array' ? Array.isArray(def) : typeof def === typeName; + + if (!isCorrectType) { + errors.push(`${label}: 'default' must be ${typeName === 'array' ? 'an array' : `of type '${typeName}'`}, got '${typeof def}'`); + } else { + if (rule.nonEmpty && (typeName === 'array' ? def.length === 0 : typeof def === 'string' && def.trim().length === 0)) { + errors.push(`${label}: 'default' must not be empty`); + } + if (rule.oneOf && Array.isArray(rule.oneOf)) { + if (typeName === 'array') { + const invalid = def.filter(v => !rule.oneOf.includes(v)); + if (invalid.length > 0) errors.push(`${label}: 'default' contains invalid value(s): ${invalid.join(', ')}`); + } else if (!rule.oneOf.includes(def)) { + errors.push(`${label}: 'default' must be one of ${rule.oneOf.join(', ')}, got '${def}'`); + } + } + } + } + + return errors; +}; + +/** + * @param {Object} config - The configuration object to validate (e.g., config.MAIN). + * @param {Array} rules - Array of validation rules. + * @param {string} rules[].key - The config key to validate. + * @param {Function} rules[].type - Expected type constructor: String, Number, Boolean, Array. + * @param {boolean} [rules[].required=true] - Whether the key must be present and non-null. + * @param {boolean} [rules[].nonEmpty] - For strings/arrays: must not be empty. + * @param {Array} [rules[].oneOf] - Allowed values (for primitive types) or allowed items (for arrays). + * @param {number} [rules[].min] - Minimum value (for numbers) or minimum length (for arrays). + * @param {number} [rules[].max] - Maximum value (for numbers). + * @param {*} [rules[].default] - Default value to assign if the key is undefined or null. + */ +const validateConfig = (config, rules) => { + const ruleErrors = rules.flatMap((rule, i) => validateRule(rule, i)); + if (ruleErrors.length > 0) throw new Error(`Invalid validation rules:\n${ruleErrors.map(e => ` - ${e}`).join('\n')}`); + + const errors = []; + + for (const rule of rules) { + const { key, required = true, nonEmpty, oneOf, min, max } = rule; + const value = config[key]; + const typeName = TYPE_NAMES.get(rule.type); + + if (value === undefined || value === null) { + if ('default' in rule) { + config[key] = rule.default; + continue; + } + if (required) errors.push(`'${key}' is required but is ${value === null ? 'null' : 'undefined'}`); + continue; + } + + if (typeName === 'array') { + if (!Array.isArray(value)) { + errors.push(`'${key}' must be an array, got ${typeof value}`); + continue; + } + + if (nonEmpty && value.length === 0) { + errors.push(`'${key}' must be a non-empty array`); + } + + if (typeof min === 'number' && value.length < min) { + errors.push(`'${key}' must have at least ${min} item(s), got ${value.length}`); + } + + if (oneOf) { + const invalid = value.filter(v => !oneOf.includes(v)); + if (invalid.length > 0) errors.push(`'${key}' contains invalid value(s): ${invalid.join(', ')}. Allowed: ${oneOf.join(', ')}`); + } + } else { + if (typeof value !== typeName) { + errors.push(`'${key}' must be of type '${typeName}', got '${typeof value}'`); + continue; + } + + if (typeName === 'string' && nonEmpty && value.trim().length === 0) { + errors.push(`'${key}' must be a non-empty string`); + } + + if (typeName === 'number') { + if (typeof min === 'number' && value < min) errors.push(`'${key}' must be >= ${min}, got ${value}`); + if (typeof max === 'number' && value > max) errors.push(`'${key}' must be <= ${max}, got ${value}`); + } + + if (oneOf && !oneOf.includes(value)) { + errors.push(`'${key}' must be one of ${oneOf.join(', ')}, got '${value}'`); + } + } + } + + if (errors.length > 0) { + logger.error(`Configuration validation failed (${errors.length}). Please check your config file: ${configPath}\n${errors.map(e => ` - ${e}`).join('\n')}`, { discord: false }); + process.exit(1); + } +}; + +module.exports = validateConfig;