diff --git a/guides/home_network_intrusion/phase_0.md b/guides/home_network_intrusion/phase_0.md new file mode 100644 index 0000000..2f49dac --- /dev/null +++ b/guides/home_network_intrusion/phase_0.md @@ -0,0 +1,54 @@ +--- +profile: home_network_intrusion +phase: 0 +title: "Before You Touch Anything" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4] +estimated_time: "15 minutes" +--- + +## Step 1: Decide whether this is a safety situation first + +If the person who may have got in is someone you know — a partner, an ex, a +housemate, a family member — stop and read this before doing anything else. + +Cutting off someone's access tells them they have been found out. If that +person has ever frightened you, the safest order of operations is different +from the technical one, and it is worth getting help planning it. In the US, +the National Domestic Violence Hotline is 1-800-799-7233. The Coalition +Against Stalkerware (stopstalkerware.org) lists services in other countries. + +If it is a stranger, a neighbour, or you have no idea, carry on to Step 2. + +## Step 2: Write down what made you suspicious + +On paper, or on a device that has never been on this network. Slow computers, +fans running constantly, a hot laptop doing nothing, unfamiliar devices in the +router's list, logins from places you have not been, someone knowing things +they should not. Note dates if you have them. + +This matters for two reasons: it is what tells you whether the problem is +fixed afterwards, and it is the beginning of a record if this ever becomes a +police report or a legal matter. + +## Step 3: Find a device and a network you can trust + +You will need somewhere safe to change passwords from, and it cannot be a +device on the affected network. A phone on mobile data — with mobile data on +and Wi-Fi switched off — is usually the easiest option. Use it for account +changes throughout this guide. + +Do not change important passwords from a machine you have not yet cleaned. If +something on it is recording keystrokes, you are handing over the new password +as you type it. + +## Step 4: Understand the order and why it matters + +The rest of this guide goes: reclaim the network, then clean the devices, then +rebuild the accounts. + +That order is deliberate. Cleaning a laptop first and then reconnecting it to +a network someone else still controls means starting again. Changing passwords +first, on an unclean machine or an intercepted network, means handing over the +new ones. It is slower to do it in this order and much faster than doing it +twice. diff --git a/guides/home_network_intrusion/phase_1.md b/guides/home_network_intrusion/phase_1.md new file mode 100644 index 0000000..6674748 --- /dev/null +++ b/guides/home_network_intrusion/phase_1.md @@ -0,0 +1,62 @@ +--- +profile: home_network_intrusion +phase: 1 +title: "See Who Is On The Network" +automatable_steps: [1, 2] +human_only_steps: [3, 4, 5] +estimated_time: "45 minutes" +--- + +## Step 1: Inventory the devices on the local network + +Run the profile's local-network checks. `lan_device_inventory` lists every +device that has recently talked to this computer, with the manufacturer behind +each hardware address where it can be identified. + +Two things to know before reading the list. It only shows devices that have +been active recently, so it can be incomplete — the router's own admin page is +the authoritative list. And modern phones deliberately use a different, +randomly generated hardware address on every network, so a phone will not +match the address printed on its box. That is privacy behaviour, not an +intruder. + +## Step 2: Check whether anything is intercepting traffic + +`arp_spoof_check` looks for the specific pattern that traffic interception +leaves behind: one device answering for addresses that belong to others, +usually the router's. + +If it reports gateway impersonation, treat the network as actively monitored. +Stop using it for anything sensitive until Phase 2 is done — no logins, no +password changes, no banking. Switch to mobile data for those. + +Mesh systems and Wi-Fi extenders can produce the same pattern legitimately. If +you have one, that is the likely explanation; confirm it before panicking. + +## Step 3: Name every device on the list + +Go through the inventory one line at a time and say what each device is out +loud: phone, laptop, TV, printer, thermostat, doorbell, games console, smart +plug. Most households are surprised by the count — twenty is normal now. + +For anything you cannot name, unplug or power off a suspected device and +re-run Step 1. The entry that disappears is that device. + +## Step 4: Cross-check against the router's own device list + +Sign in to the router (Phase 2 covers how) and open its list of connected +devices — it may be called Attached Devices, Device List, Client List, or +DHCP Clients. It shows devices your computer has not spoken to, which the scan +in Step 1 cannot see. + +Compare the two lists. Anything on the router's list that you cannot account +for is the thing to focus on. + +## Step 5: Do not bother blocking devices by hardware address + +Most routers offer MAC filtering, and it feels like the obvious answer. It is +not: a hardware address can be changed in seconds, and blocking one only tells +the intruder which address to stop using. + +The change that actually removes everyone you have not authorised is a new +Wi-Fi passphrase, which is Phase 2. diff --git a/guides/home_network_intrusion/phase_2.md b/guides/home_network_intrusion/phase_2.md new file mode 100644 index 0000000..818250f --- /dev/null +++ b/guides/home_network_intrusion/phase_2.md @@ -0,0 +1,88 @@ +--- +profile: home_network_intrusion +phase: 2 +title: "Take The Router Back" +automatable_steps: [1] +human_only_steps: [2, 3, 4, 5, 6, 7, 8] +estimated_time: "1 hour" +--- + +## Step 1: Audit what the router is offering to the network + +Run `router_security_audit`. It checks which administrative services your +router is offering to every device on the Wi-Fi — remote login services, the +admin page, provider management interfaces — and whether UPnP is enabled. + +Everything after this step happens in the router's own settings, because the +settings that decide whether someone can get back in are not visible from +outside it. + +## Step 2: Sign in to the router, ideally over a cable + +Connect a computer to the router with an Ethernet cable if you can, so the +next steps do not travel over a network someone else may be watching. Then +open the gateway address the audit reported in a browser. + +If the admin password is still the one printed on the underside of the router, +assume the router has already been reconfigured by someone else, and read +every setting below rather than trusting any of them. + +## Step 3: Update the firmware first + +Look for Firmware Update, Router Update, or Administration. Install whatever +is offered and let it reboot. + +Do this before anything else. If the router has a known vulnerability, new +passwords do not help — the way in was never the password. + +## Step 4: Change the admin password and the Wi-Fi passphrase + +Two different passwords, both long, neither used anywhere else: + +- The **admin password** protects the router's settings. +- The **Wi-Fi passphrase** is what devices use to join. + +Set the wireless security mode to WPA3. If the router does not offer it, +WPA2-AES (sometimes shown as WPA2-PSK AES) is acceptable. Never WEP, never +WPA/TKIP, never open — those are broken and can be cracked in minutes by +someone parked outside. + +## Step 5: Turn off WPS + +WPS lets a device join using an eight-digit PIN instead of the passphrase, and +that PIN can be broken offline. Leaving it on makes the strong passphrase you +just set irrelevant. Turn it off. + +## Step 6: Turn off remote administration, and check the DNS servers + +Find Remote Management, Remote Access, Web Access from WAN, or cloud +management, and turn it off. The router's settings should only be reachable +from inside the house. + +Then open the Internet or WAN page and look at the DNS servers. They should be +your provider's, or a resolver you deliberately chose (for example 1.1.1.1 or +9.9.9.9). Anything else means someone redirected every device in the house to +a name server they control — every phone, TV, and laptop, without touching any +of them. Reset it if it is not what you expect. + +## Step 7: Clear out port forwarding, DMZ, and the guest network + +Delete every port-forwarding rule you did not create yourself, and switch off +any "DMZ host". These are open doors from the internet to a specific device +inside the house, and UPnP can create them automatically on a program's +request. + +Check the guest network too. An open guest network with no password is a +second way onto the same hardware. + +## Step 8: If the settings will not stick, factory reset + +If changes do not save, or old settings reappear, the router itself is +compromised. Hold the reset pin for 30 seconds to factory reset it, then set it +up again from scratch. + +Do not restore a saved configuration backup — that restores the intruder's +changes along with yours. Set it up by hand. + +When the router is done, re-run Step 1 and Phase 1 to confirm the network +looks the way you expect. diff --git a/guides/home_network_intrusion/phase_3.md b/guides/home_network_intrusion/phase_3.md new file mode 100644 index 0000000..cc8bbc4 --- /dev/null +++ b/guides/home_network_intrusion/phase_3.md @@ -0,0 +1,92 @@ +--- +profile: home_network_intrusion +phase: 3 +title: "Clean The Devices" +automatable_steps: [1, 2, 3, 4] +human_only_steps: [5, 6, 7] +estimated_time: "1-2 hours per device" +--- + +## Step 1: Look for cryptocurrency mining + +Run `crypto_miner_detect` (macOS) or `win_crypto_miner_detect` (Windows). +These look at what is running right now: miner process names, command lines +containing a mining pool or a wallet address, and live connections to mining +pool ports. + +Mining is what a hot, loud, slow machine usually turns out to be. It is also +rarely the first thing that happened — it is what someone does with access +they already had, which is why the rest of this guide exists. + +## Step 2: Find what restarts the miner + +Run `crypto_miner_persistence`. Killing a miner achieves nothing if a startup +entry launches it again a minute later, and that is the normal arrangement. + +This check looks at startup items, scheduled tasks, cron entries, service +definitions, and shell startup files for mining commands, and for the miner's +own configuration file — the one containing the pool address and the wallet +being paid. + +Note down the wallet address before deleting anything. It is the clearest +evidence of what was happening. + +## Step 3: Check the browser + +Run `browser_cryptojacking_check`. Mining does not need to be a program: a +browser extension can mine for as long as the browser is open, and process +lists will only ever show Chrome. + +This checks installed extensions for mining code, startup pages set to mining +sites, and hosts-file entries pointing mining domains somewhere unexpected. + +## Step 4: Check who else has access to the machine + +Run `stalkerware_scan`, `remote_login_check` (macOS) or +`win_remote_access_audit` (Windows), and `process_scanner`. + +Three different things come out of this, and they need to be told apart: + +- **Monitoring software** sold for watching another person. If nobody told you + it was there, someone installed it to watch what you do. Re-read Phase 0 + Step 1 before removing it. +- **Remote access tools** — TeamViewer, AnyDesk, VNC, ScreenConnect. Ordinary + software that gives someone complete control of the screen. Common leftovers + from "tech support" phone scams. +- **Adware and fake cleaners**, which are a nuisance rather than surveillance, + but tend to arrive by the same route. + +## Step 5: Remove in the right order + +For each thing found: + +1. Record it first — a screenshot, the file path, the date. +2. Remove the startup entry, then reboot. +3. Delete the program itself. +4. Re-run the checks. + +If something reappears after a reboot, stop. Something else on the machine +still has enough privilege to rebuild it, and removing symptoms one at a time +will not get ahead of it. That is the point at which a full operating-system +reinstall is the faster and more certain option. + +## Step 6: Do every device, not just the interesting one + +Everything that was on the network needs looking at: every laptop and desktop, +phones and tablets, and the devices nobody thinks of as computers — the TV, +the streaming stick, the cameras, the printer, the smart speakers. + +For the ones you cannot scan, the practical action is the same: install +pending updates, change any password associated with them, and factory reset +anything that behaves oddly. Cameras and video doorbells deserve particular +attention, because access to them is access to the inside of the house. + +## Step 7: Consider a clean reinstall for the worst device + +A full reinstall is the only way to be certain, and on the machine that was +most affected it is often less work than repeatedly chasing things that come +back. + +If you do it: back up documents and photos only, never applications or system +settings, and never restore a full system image made while the machine was +compromised. diff --git a/guides/home_network_intrusion/phase_4.md b/guides/home_network_intrusion/phase_4.md new file mode 100644 index 0000000..c2956c9 --- /dev/null +++ b/guides/home_network_intrusion/phase_4.md @@ -0,0 +1,69 @@ +--- +profile: home_network_intrusion +phase: 4 +title: "Rebuild Accounts And Keep Them Out" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6] +estimated_time: "2 hours, then 15 minutes a month" +--- + +## Step 1: Only now, change the passwords + +The network is reclaimed and the devices are clean, so a new password will not +be captured the moment it is typed. Work from a device you trust. + +Order matters: email first, because it is the reset path for everything else. +Then banking and payment accounts, then anything storing personal data, then +the rest. Each password long, unique, and stored in a password manager rather +than remembered. + +## Step 2: Turn on two-factor authentication, starting with email + +Use an authenticator app rather than SMS where the choice exists — text +messages can be redirected by someone who can talk a phone company into moving +a number. + +Save the recovery codes somewhere that is not the device you are protecting. + +## Step 3: Sign out everything else + +Every major service has a page listing active sessions and connected devices — +look for "Where you're signed in", "Devices", or "Security activity". Sign out +of everything, everywhere, and check the list of connected apps while you are +there. + +A password change does not always end sessions that are already open. This is +what actually removes someone who was still signed in. + +## Step 4: Check the accounts attached to the house, not just to you + +These get missed, and they are how people get back in: + +- The router's own cloud or mobile-app account. +- Cameras, doorbells, thermostats, smart plugs, and speakers. +- Streaming and TV accounts. +- Any shared family account, and any account where the recovery email or + phone number is someone else's. + +For each, change the password and look at who else it is shared with. + +## Step 5: Separate the things that do not need to be together + +Two changes that make the next attempt much harder: + +- Put smart-home gadgets and cameras on the guest network, with its own + password. Cheap devices are the usual way in, and the guest network keeps + them away from your laptops and files. +- Keep the router's admin password different from the Wi-Fi password, so + giving a visitor the Wi-Fi password does not give away the router. + +## Step 6: Set a date to check again + +Put a reminder in the calendar for a month's time and re-run this profile. +What you are looking for is whether the device list has grown, whether anything +is intercepting traffic again, and whether mining or monitoring software has +come back. + +If it has come back after all of this, the entry point was never found. That is +the point to bring in a professional rather than going round again — and if the +person who got in is someone you know, to go back to Phase 0, Step 1. diff --git a/guides/home_network_intrusion/phase_5.md b/guides/home_network_intrusion/phase_5.md new file mode 100644 index 0000000..6c556ce --- /dev/null +++ b/guides/home_network_intrusion/phase_5.md @@ -0,0 +1,120 @@ +--- +profile: home_network_intrusion +phase: 5 +title: "Resources, Tiplines, And Free Help" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6] +estimated_time: "reference — use as needed" +--- + +## Step 1: Do not call a support number you found by searching + +Search adverts for "router support", "remove virus", and "Wi-Fi security help" +are routinely bought by scam call centres. Their entire business is people in +exactly this situation. The call ends with remote access software installed on +the machine and a payment made — which is how a fair number of the compromises +this toolkit finds got started. + +Type the official domain yourself, or use the number printed on the device or +on a paper bill. Nobody legitimate asks for payment in gift cards, +cryptocurrency, wire transfers, or a payment app. + +Details below were accurate at the time of writing; verify on the official +site if something looks different. + +## Step 2: If the person who got in is someone you know + +This changes the order of everything. Removing their access tells them they +have been found out, and that is the point at which situations escalate. Talk +to an advocate before making the technical changes, not after. + +- **National Domestic Violence Hotline** (US) — 1-800-799-7233, TTY + 1-800-787-3224, or text START to 88788 — thehotline.org +- **NNEDV Safety Net** — techsafety.org — the specialists on + technology-facilitated abuse: shared accounts, location tracking, smart-home + devices, and stalkerware +- **Coalition Against Stalkerware** — stopstalkerware.org — international + directory of services +- **SPARC** — stalkingawareness.org +- **Operation Safe Escape** — safeescape.org — practical security support for + people leaving abusive situations +- **RAINN** — 1-800-656-4673 +- **UK** — National Domestic Abuse Helpline 0808 2000 247; Refuge's tech abuse + team at refugetechsafety.org +- **Australia** — 1800RESPECT, 1800 737 732; eSafety Commissioner, + esafety.gov.au + +Smart-home devices deserve specific mention: thermostats, cameras, doorbells, +locks, and speakers are often still linked to an account the other person +controls. An advocate will help you work out the safe order to unpick that. + +## Step 3: Free expert help with a compromised device or network + +- **Access Now Digital Security Helpline** — accessnow.org/help — free, 24/7, + nine languages, for journalists, activists, human rights defenders, and + civil society. Real incident responders. +- **Citizen Lab** and **Amnesty International Security Lab** — for anyone who + may be targeted by state-grade or mercenary spyware; both accept referrals + and publish forensic tooling. +- **CISA** (US) — cisa.gov/report — report@cisa.gov — 1-844-729-2472. +- **Consumer Reports Security Planner** — + securityplanner.consumerreports.org — free personalised hardening checklist. +- **EFF Surveillance Self-Defense** — ssd.eff.org — plain-language guides on + threat modelling and network security. + +## Step 4: Where to report the intrusion + +Reporting rarely gets one household's router fixed, but it is what makes +patterns visible, and some reports do get acted on. + +- **FBI Internet Crime Complaint Center** — ic3.gov — the main US channel for + computer intrusion. +- **ReportFraud.ftc.gov** — for scams, tech-support fraud, and unauthorised + charges. +- **Your internet provider's abuse team** — worth a call if the intrusion came + through provider-supplied equipment, or if provider remote management + (TR-069) is exposed. They can push firmware and reset the device remotely. +- **Local police** — necessary if there is any stalking, harassment, or + threat involved, and required before some other protections become + available. +- **UK** — Action Fraud, 0300 123 2040, actionfraud.police.uk; NCSC at + ncsc.gov.uk/report. +- **Canada** — Canadian Anti-Fraud Centre, 1-888-495-8501; Canadian Centre for + Cyber Security. +- **Australia/NZ** — ReportCyber at cyber.gov.au; CERT NZ; IDCARE at + idcare.org, 1800 595 160 (AU) / 0800 121 068 (NZ). + +## Step 5: If money or accounts were touched, switch guides + +Network intrusion that reaches accounts becomes a different job with its own +deadlines — freezes, official reports, and disputes, in that order. + +Run `rescue guide identity_theft_recovery` for the full sequence. The numbers +that matter most quickly: + +- **IdentityTheft.gov** (FTC) — 1-877-438-4338 — produces the report that + obliges credit bureaus and creditors to act +- **Identity Theft Resource Center** — idtheftcenter.org — 1-888-400-5530 — + free advisors who stay with your case +- Credit freezes: Equifax 1-800-685-1111, Experian 1-888-397-3742, TransUnion + 1-888-909-8872 — free, and the fastest way to stop new damage +- Your bank's fraud line, from the back of the card — not from a search result + +## Step 6: Understanding the equipment and the failure modes + +Useful when deciding whether a router is worth keeping: + +- **Router firmware and end-of-life status** — check the manufacturer's + support page for your exact model. A router no longer receiving security + updates cannot be made safe by configuration, and replacing it is the fix. +- **routersecurity.org** — an independently maintained, vendor-neutral + reference on router settings and their trade-offs. +- **CISA Known Exploited Vulnerabilities catalogue** — cisa.gov/kev — searching + it for your router's make tells you whether its flaws are actively being + used. +- **haveibeenpwned.com** — which breaches include your addresses, which often + explains where the Wi-Fi password was reused from. + +If unknown devices keep reappearing after a factory reset and a firmware +update, stop troubleshooting and replace the router. At that point the cost of +a new one is lower than the cost of continuing to guess. diff --git a/guides/identity_theft_recovery/phase_0.md b/guides/identity_theft_recovery/phase_0.md new file mode 100644 index 0000000..991bf7d --- /dev/null +++ b/guides/identity_theft_recovery/phase_0.md @@ -0,0 +1,63 @@ +--- +profile: identity_theft_recovery +phase: 0 +title: "The First Hour" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5] +estimated_time: "1 hour" +--- + +## Step 1: Start a recovery log before you do anything else + +Open a notebook, or a document on a device you trust, and put today's date at +the top. From here on, every call and every letter gets one line: the date, +the organisation, the person's name, what they said, and any reference number +they gave you. + +This feels like busywork on day one. It is the single most useful thing you +will do. Recovery runs on being able to say "I reported this on the 4th, my +reference is X, and I was told Y" — often months later, to someone who has no +record of the previous conversation. + +## Step 2: Write down what you already know + +What made you realise? A charge you did not make, a letter about an account +you never opened, a rejected tax return, a debt collector, a denied +application, a breach notification. Note the dates and the amounts. + +You will be asked to repeat this story to a dozen organisations. Having it +written down once means it stays consistent, and consistency matters when +somebody is deciding whether to believe you. + +## Step 3: Know what identity theft is not your fault + +You did not cause this by clicking something. The overwhelming majority of +identity theft starts with data that a company lost — a breach, a stolen +laptop, a contractor's database. Reconstructing what you "should have done +differently" burns the energy you need for the next four phases. + +There is also a practical reason not to blame yourself: people who feel +foolish tend to under-report, and under-reporting is what costs money later. + +## Step 4: Understand the order, and why it is this order + +The rest of this guide runs: check the device, freeze the accounts, file the +official reports, dispute the damage, then monitor. + +Freezing comes before reporting because a freeze stops new damage while the +paperwork moves. Reporting comes before disputing because the official report +is what obliges credit bureaus and creditors to act — dispute letters sent +without one get form-letter replies. + +## Step 5: Decide what to do about the immediate money + +If money has actually left an account, that is the one thing that does not +wait for the sequence. Call the bank's fraud line — the number on the back of +the card, not one from a search result or an email — and say the words +"unauthorised transaction" and "fraud". + +Timing rules are real and short. In the US, unauthorised debit card charges +are capped at $50 if you report within two business days of noticing, and can +rise to $500 or the whole balance after that. Credit cards have stronger +protections than debit cards, which is the practical reason to use a credit +card online. diff --git a/guides/identity_theft_recovery/phase_1.md b/guides/identity_theft_recovery/phase_1.md new file mode 100644 index 0000000..6271227 --- /dev/null +++ b/guides/identity_theft_recovery/phase_1.md @@ -0,0 +1,60 @@ +--- +profile: identity_theft_recovery +phase: 1 +title: "Make Sure The Device Is Not The Leak" +automatable_steps: [1, 2, 3] +human_only_steps: [4, 5] +estimated_time: "45 minutes" +--- + +## Step 1: Scan for monitoring and malware on this device + +Run the profile's device checks: `stalkerware_scan`, `keylogger_indicators`, +`malware_scan_indicators` or `win_malware_indicators`, `suspicious_processes` +or `win_suspicious_processes`, and `process_scanner`. + +The point is narrow. You are about to type new passwords, account numbers, and +possibly a Social Security number into this machine. If something is recording +keystrokes, every step after this hands the thief a fresh copy. + +## Step 2: Check the browser, where credential theft usually lives + +Run `browser_extension_audit`, `browser_hijack_check`, and +`certificate_trust_audit`. + +An extension with permission to read every page can read your banking session +as easily as you can. A rogue root certificate lets whoever installed it read +traffic that the padlock says is encrypted. Both are quieter than malware and +both are commonly how account access outlives a password change. + +## Step 3: Check whether someone else is still logged in + +Run `remote_login_check` or `win_remote_access_audit`. + +Remote access left switched on is the most boring explanation for ongoing +fraud, and the most common one after a "tech support" phone call. + +## Step 4: If anything was found, do the recovery from a different device + +Do not clean the machine and immediately carry on. Borrow a device, use a +phone on mobile data, or use a library computer for the account changes, and +come back to cleaning this one afterwards. + +If the checks found something serious, the `digital_security_reset` profile +covers the cleanup, and a full operating-system reinstall is the only way to +be certain. + +## Step 5: Secure your email and phone before anything else + +Email and phone number are the recovery path for every other account, which +makes them the two things worth over-protecting: + +- New, unique password on the primary email account, and two-factor + authentication turned on — an authenticator app rather than SMS. +- Check the email account's forwarding rules and filters. A rule quietly + forwarding or deleting mail is how a thief keeps reading your alerts after + you have changed the password. It survives password changes; look for it + explicitly. +- Call your mobile carrier and add a port-out PIN or a SIM-swap lock to the + account. Without one, a thief who can convince a shop assistant to move your + number receives every SMS code you have. diff --git a/guides/identity_theft_recovery/phase_2.md b/guides/identity_theft_recovery/phase_2.md new file mode 100644 index 0000000..4e73f5a --- /dev/null +++ b/guides/identity_theft_recovery/phase_2.md @@ -0,0 +1,99 @@ +--- +profile: identity_theft_recovery +phase: 2 +title: "Freeze Everything (Stop New Damage)" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6, 7] +estimated_time: "2 hours" +--- + +## Step 1: Freeze your credit at all three major bureaus + +A credit freeze stops anyone — including you — from opening new credit in +your name until you lift it. It is free, it does not affect your credit score, +and it is the single most effective thing on this list. + +In the US, all three, because creditors do not all check the same one: + +- Equifax — equifax.com/personal/credit-report-services — 1-800-685-1111 +- Experian — experian.com/freeze — 1-888-397-3742 +- TransUnion — transunion.com/credit-freeze — 1-888-909-8872 + +Save the PIN or account login each one gives you into your password manager. +Losing it turns a two-minute unfreeze into a paperwork exercise later. + +A freeze is not the same as the "credit lock" a bureau will try to sell you. +The freeze is the one backed by federal law and required to be free. + +## Step 2: Freeze the three bureaus nobody mentions + +Fraud that gets refused at the big three often succeeds at these, because +utility companies, phone carriers, and banks check different databases: + +- Innovis (fourth credit bureau) — innovis.com — 1-800-540-2505 +- NCTUE (utility and telecom accounts) — nctue.com — 1-866-349-5355 +- ChexSystems (new bank accounts) — chexsystems.com — 1-800-428-9623 + +Opening a phone contract or a chequing account in your name is a normal next +move for a thief, and neither goes through the big three. + +## Step 3: Place a fraud alert + +A fraud alert tells lenders to take extra steps to verify identity before +granting credit. Placing one with any single bureau obliges it to notify the +other two. + +An initial alert lasts one year. Once you have the FTC identity theft report +from Phase 3, you can upgrade to an extended alert lasting seven years — which +is why this step is worth revisiting after the next phase. + +## Step 4: Call every bank and card issuer, not just the affected one + +For each institution: report the fraud, ask them to add a fraud flag to your +file, and ask what internal verification they can add — a passphrase, a +call-back requirement, or a note that in-branch identification is required. + +Ask each one directly: "Are there any accounts, cards, or applications on file +that I have not told you about?" That question surfaces accounts opened in +your name at a bank you already use, which no credit report search will show +you as quickly. + +## Step 5: Change passwords on financial and government accounts + +Bank, card issuers, payment apps, brokerage, tax authority, benefits, pension, +insurance. New unique password each, two-factor authentication on each, from a +device you cleared in Phase 1. + +Where the account offers it, change the security questions too. Mother's +maiden name and first school are exactly the details that get bought in bulk. + +## Step 6: Reclaim the mail + +Physical mail is still a live channel for identity theft. Check that no +change-of-address request has been filed in your name — in the US, the Postal +Service sends a confirmation letter to the old address when one is filed, so +look back through recent post for one you did not request. + +Sign up for the postal service's daily mail preview if it offers one, so you +can see what should be arriving. If mail has gone missing, report it to the +Postal Inspection Service at uspis.gov or 1-877-876-2455. + +## Step 7: Outside the US, use these equivalents + +The sequence in this guide holds everywhere; the institutions differ. + +- **UK** — report to Action Fraud (actionfraud.police.uk, 0300 123 2040) and + take out a Cifas Protective Registration (cifas.org.uk), which is the + closest equivalent to a freeze. Credit files are held by Experian, Equifax, + and TransUnion UK. +- **Canada** — Canadian Anti-Fraud Centre (antifraudcentre.ca, 1-888-495-8501), + then fraud alerts with Equifax Canada and TransUnion Canada. +- **Australia** — IDCARE (idcare.org) provides free case-managed support; also + report through ReportCyber and request a credit ban with Equifax, Experian, + and illion. +- **EU/EEA** — report to national police, and to your data protection + authority if a company's breach caused it; national banking ombudsmen handle + disputed transactions. + +Whatever the country: report to the police, freeze or flag the credit file, +and keep the reference numbers. Everything after that is the same work. diff --git a/guides/identity_theft_recovery/phase_3.md b/guides/identity_theft_recovery/phase_3.md new file mode 100644 index 0000000..2676330 --- /dev/null +++ b/guides/identity_theft_recovery/phase_3.md @@ -0,0 +1,105 @@ +--- +profile: identity_theft_recovery +phase: 3 +title: "Report It Officially" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6, 7] +estimated_time: "2-3 hours" +--- + +## Step 1: File the FTC report at IdentityTheft.gov + +This is the keystone step in the US, and it is free. IdentityTheft.gov walks +through what happened and produces two things: + +- An **FTC Identity Theft Report**, which is a legal document. It is what + entitles you to have fraudulent accounts blocked from your credit report, to + the seven-year extended fraud alert, and to demand that creditors stop + collecting on debts that are not yours. +- A personalised recovery plan with pre-filled dispute letters. + +Download the report and save several copies. You will be attaching it to +almost everything in Phase 4. + +## Step 2: File a police report + +Take your FTC report, photo identification, proof of address, and your log of +fraudulent accounts to the local police, or use the online reporting form if +your force has one. + +Some officers will tell you it is not worth filing because the thief is +untraceable. File anyway, and ask for the report number: several creditors +will not process a fraud claim without one, and some states issue an identity +theft passport or clearance letter that only exists off the back of a police +report. + +## Step 3: Protect the tax file + +Tax refund fraud is one of the most common uses of a stolen Social Security +number, and you often find out by having a legitimate return rejected as a +duplicate. + +In the US: + +- Get an **Identity Protection PIN** at irs.gov/ippin. It is a six-digit code, + reissued annually, without which no return can be filed under your number. + Anyone can opt in — you do not have to be a confirmed victim. +- If a return has already been filed in your name, file **Form 14039** + (Identity Theft Affidavit), and call the IRS Identity Protection Specialized + Unit at 1-800-908-4490. +- Contact your state tax authority separately. The federal filing does not + reach them. + +## Step 4: Report Social Security number misuse + +If the number itself has been used — for employment, benefits, or credit: + +- Report to the SSA Office of the Inspector General at oig.ssa.gov, or + 1-800-269-0271. +- Create an account at ssa.gov and check your earnings record for wages you + did not earn, which is the visible sign of someone working under your + number. +- Use E-Verify Self Lock (e-verify.gov) to block your number from being used + in employment verification. + +Getting a new Social Security number is possible but rarely advisable — a new +number with no history creates its own problems, and the old one stays linked. + +## Step 5: Report to the financial regulator + +In the US, file a complaint with the Consumer Financial Protection Bureau at +consumerfinance.gov/complaint for any bank, card issuer, credit bureau, or +debt collector that is not cooperating. Companies have to respond, usually +within 15 days, and it creates a paper trail that gets slow cases moving. + +Complaints to your state attorney general (naag.org lists them) are the +equivalent lever for businesses outside the CFPB's remit. + +## Step 6: Report the specific flavour of theft you are dealing with + +Each of these has its own channel, and skipping it leaves the damage in place: + +- **Phone number stolen (SIM swap or port-out)** — carrier fraud department + first, then the FCC at fcc.gov/complaints. +- **Medical identity theft** — call each provider and insurer, ask for an + accounting of disclosures, and request corrections to the medical record. + Wrong blood type or allergies in your file is a safety issue, not just a + billing one. Medicare fraud: 1-800-MEDICARE. +- **Criminal identity theft** (someone arrested using your name) — contact the + arresting agency and the court directly; ask about an identity theft + passport or clearance letter. +- **A child's identity** — the bureaus will create and immediately freeze a + minor's credit file on request, free of charge. Check for one; a child + should have no credit file at all. +- **A deceased relative's identity** — send a copy of the death certificate to + all three bureaus and ask for the file to be flagged "deceased — do not + issue credit". + +## Step 7: Update the log, and file everything in one place + +Add every reference number from this phase to the log: FTC report number, +police report number, IRS and SSA case numbers, CFPB complaint number. + +Keep one folder — paper or a single encrypted archive — with the FTC report, +the police report, and your log. Phase 4 is largely a matter of attaching +these same three documents to a dozen different disputes. diff --git a/guides/identity_theft_recovery/phase_4.md b/guides/identity_theft_recovery/phase_4.md new file mode 100644 index 0000000..73951d0 --- /dev/null +++ b/guides/identity_theft_recovery/phase_4.md @@ -0,0 +1,83 @@ +--- +profile: identity_theft_recovery +phase: 4 +title: "Dispute And Undo The Damage" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6] +estimated_time: "3 hours, then several weeks of follow-up" +--- + +## Step 1: Pull all three credit reports and mark up every line + +Get them free at annualcreditreport.com — the official US site, free weekly +for all three bureaus. Ignore anything that asks for a card number. + +Read every page and mark: accounts you did not open, addresses you have never +lived at, employers you have never worked for, and enquiries you did not +authorise. Addresses matter more than people expect — a thief's mailing +address on your file is how they receive the cards. + +Do this for all three separately. Fraud frequently appears on only one. + +## Step 2: Block the fraudulent accounts, do not merely dispute them + +There are two different processes and they get confused constantly: + +- A **dispute** asks the bureau to investigate. They have 30 days, and the + creditor can simply reassert the debt. +- An **identity theft block** (US, FCRA §605B) requires the bureau to remove + the information within four business days, once you give them your FTC + identity theft report. It is a stronger right and it is faster. + +Send the block request with a copy of the FTC report, proof of identity, and a +list of exactly which items are fraudulent. Use the letters IdentityTheft.gov +generated for you. + +## Step 3: Notify each creditor directly, in writing + +The bureau is not the creditor. Write to the fraud department of every company +where an account was opened, attaching the FTC report and the police report, +and asking them to: + +1. Close the account as fraudulent, effective from the date opened. +2. Confirm in writing that you are not liable. +3. Stop reporting it to the credit bureaus. +4. Provide the application records and transaction history — in the US you can + demand these under FCRA §609(e), and they are useful evidence of where the + thief operated. + +Send by certified mail with return receipt, or the local equivalent. Email is +fine as a courtesy copy but proof of delivery is what settles arguments. + +## Step 4: Deal with the debt collectors properly + +When a collector contacts you about a fraudulent debt, respond in writing +within 30 days: state that the debt is the result of identity theft, request +validation of the debt, and enclose the FTC report. + +Once notified in writing, a collector must stop collection activity until it +has verified the debt, and must tell whoever it is collecting for that the +debt is disputed as identity theft. Do not agree to a payment plan, do not +make a good-faith partial payment, and do not acknowledge the debt as yours — +any of those can restart a limitation period. + +## Step 5: Set a follow-up schedule and expect to repeat yourself + +Disputes go quiet. Put dates in the calendar now: + +- Day 5: confirm each block request was received. +- Day 30: check the credit reports again to confirm removal. +- Day 45: escalate anything still present to the CFPB. +- Day 90: pull all three reports again — fraudulent accounts sometimes + reappear when a creditor re-sells the debt to a new collector. + +Reappearing accounts are normal and do not mean you did it wrong. Each new +collector is a new party who has not seen your paperwork. + +## Step 6: Log every dispute with its deadline + +For each item: which bureau or creditor, what you sent, the date sent, the +delivery confirmation number, the legal deadline, and the outcome. + +When something goes wrong six months from now, this table is what turns "I +think I sent something" into a CFPB complaint that gets resolved in two weeks. diff --git a/guides/identity_theft_recovery/phase_5.md b/guides/identity_theft_recovery/phase_5.md new file mode 100644 index 0000000..6001dd0 --- /dev/null +++ b/guides/identity_theft_recovery/phase_5.md @@ -0,0 +1,86 @@ +--- +profile: identity_theft_recovery +phase: 5 +title: "Monitor, Rebuild, And Look After Yourself" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6] +estimated_time: "1 hour, then 20 minutes a month" +--- + +## Step 1: Upgrade to the seven-year extended fraud alert + +Now that you have the FTC identity theft report, ask one credit bureau for an +**extended fraud alert**. It lasts seven years, obliges lenders to contact you +directly before granting credit, and removes you from prescreened credit offer +lists for five years. The bureau you ask must notify the other two. + +Keep the credit freezes in place as well. The freeze blocks; the alert warns. +They do different jobs. + +## Step 2: Stop the prescreened offers that fuel the fraud + +Pre-approved credit offers arriving in the post are raw material for identity +theft. Opt out at optoutprescreen.com or 1-888-567-8688 — five years online, +permanently if you post the signed form. + +While you are at it, shred rather than bin anything with an account number on +it, and consider a locking mailbox if yours is accessible from the street. + +## Step 3: Set the monitoring rhythm + +- **Weekly for the first three months**: check bank and card transactions. +- **Monthly**: pull one credit report — rotate through the three bureaus, so + you see each one every quarter for free. +- **Annually**: renew the IRS Identity Protection PIN (it is reissued each + year), check your SSA earnings record, and review who has access to your + accounts. + +Turn on transaction alerts at every bank and card issuer — a text for every +transaction over a small threshold catches fraud in hours instead of at the +end of the statement period. + +## Step 4: Fix the structural weaknesses while you are here + +The recovery is also the opportunity: + +- Password manager, unique password everywhere, no exceptions. +- Authenticator app instead of SMS for two-factor, wherever it is offered. +- Answers to security questions that are not facts about your life. They are + just second passwords; store the made-up answers in the password manager. +- A separate email address used only for financial accounts, never given out + publicly or used for shopping. +- Check haveibeenpwned.com for which breaches your addresses appear in, so you + know which accounts to prioritise. + +## Step 5: Know what to expect over the next two years + +Identity theft has a long tail, and none of the following means you failed: + +- Fraudulent accounts reappearing after being removed, usually via a new debt + collector. +- New fraud attempts using the same stolen data, months apart, because the + data is resold repeatedly. +- Credit applications taking longer, or requiring a phone call, because of the + fraud alert. That is the alert working. +- Finding one more account you had not noticed. Go back to Phase 4 for that + one item; you do not restart the whole process. + +Keep the recovery log for at least seven years. It is the difference between +handling a recurrence in an afternoon and starting from scratch. + +## Step 6: Acknowledge what this cost you + +Identity theft recovery takes most people somewhere between a few weeks and +several months of intermittent, tedious, infuriating work, usually while being +treated with suspicion by organisations that should be helping. The emotional +weight of that is real, and it is not proportional to the amount of money +involved. + +Tell one person you trust what happened. If it is affecting your sleep or your +ability to function, that is a normal response to a violation, and worth +mentioning to a doctor or a counsellor. + +If you are in the US, the Identity Theft Resource Center (idtheftcenter.org, +1-888-400-5530) offers free case-managed help from real advisors, and is worth +calling when the paperwork stops making sense. In Australia, IDCARE +(idcare.org) does the same. Neither charges. diff --git a/guides/identity_theft_recovery/phase_6.md b/guides/identity_theft_recovery/phase_6.md new file mode 100644 index 0000000..2c18284 --- /dev/null +++ b/guides/identity_theft_recovery/phase_6.md @@ -0,0 +1,150 @@ +--- +profile: identity_theft_recovery +phase: 6 +title: "Resources, Tiplines, And Free Help" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6, 7, 8] +estimated_time: "reference — use as needed" +--- + +## Step 1: Before you call anything, know how the fake helplines work + +Search results for "Equifax fraud number" or "IRS help line" are bought by +scam call centres, and the people who answer are convincing. Somebody already +in the middle of identity theft recovery is exactly who they are hoping to +reach. + +Two rules that make this safe: + +- Type the official domain into the address bar yourself, or use the number on + the back of your card or on a paper statement. Do not call a number from a + search advert, a text, an email, or a pop-up. +- No legitimate agency will ever ask you to pay by gift card, wire transfer, + cryptocurrency, or payment app. There is no fee to freeze credit, file an + FTC report, or get an IRS Identity Protection PIN. Anyone charging for those + is either reselling something free or stealing from you. + +Details below were accurate at the time of writing; verify on the official +site if something looks different. + +## Step 2: Free case-managed help from real people + +These will walk through your specific situation with you, at no cost. Call +them when the paperwork stops making sense — it is what they are for. + +- **Identity Theft Resource Center** (US) — idtheftcenter.org — + 1-888-400-5530, or text IDTHEFT to 88788. Free advisors who stay with your + case, not a one-off call. +- **AARP Fraud Watch Network Helpline** — 1-877-908-3360. Free and open to + anyone of any age, not only AARP members. +- **National Elder Fraud Hotline** (US Dept of Justice) — 1-833-372-8311. Case + managers for victims aged 60 and over. +- **IDCARE** (Australia and New Zealand) — idcare.org — 1800 595 160 (AU), + 0800 121 068 (NZ). Free case-managed identity support. +- **Access Now Digital Security Helpline** — accessnow.org/help. Free, 24/7, + in nine languages, for journalists, activists, and civil society. + +## Step 3: The official reporting channels + +- **IdentityTheft.gov** (FTC) — the report that unlocks your legal rights. + Phone: 1-877-438-4338, TTY 1-866-653-4261. +- **ReportFraud.ftc.gov** — for scams and fraud that are not identity theft. +- **FBI Internet Crime Complaint Center** — ic3.gov. +- **CFPB** (banks, card issuers, bureaus, collectors that will not cooperate) — + consumerfinance.gov/complaint — 1-855-411-2372. +- **IRS** — identity theft central at irs.gov/identity-theft-central, + Identity Protection PIN at irs.gov/ippin, Identity Protection Specialized + Unit 1-800-908-4490. +- **Social Security Administration** — fraud reporting oig.ssa.gov or + 1-800-269-0271; general 1-800-772-1213. +- **US Postal Inspection Service** (mail theft, forged change of address) — + uspis.gov — 1-877-876-2455. +- **Medicare fraud** — 1-800-633-4227; Senior Medicare Patrol at + smpresource.org for help reading the statements. +- **Your state attorney general** — directory at naag.org. + +## Step 4: The credit bureaus and the databases nobody mentions + +Freezes are free by law. Do all of them; creditors do not check the same one. + +- Equifax — equifax.com/personal/credit-report-services — 1-800-685-1111 +- Experian — experian.com/freeze — 1-888-397-3742 +- TransUnion — transunion.com/credit-freeze — 1-888-909-8872 +- Innovis (fourth bureau) — innovis.com — 1-800-540-2505 +- NCTUE (utility and phone accounts) — nctue.com — 1-866-349-5355 +- ChexSystems (new bank accounts) — chexsystems.com — 1-800-428-9623 +- LexisNexis and SageStream also hold consumer files and accept freezes + +Reports and opt-outs: + +- **annualcreditreport.com** — 1-877-322-8228 — the only federally authorised + free source. Free weekly from all three. +- **optoutprescreen.com** — 1-888-567-8688 — stops prescreened credit offers. +- **dmachoice.org** — reduces marketing mail. + +## Step 5: If the person who did this knows you + +Identity theft by a partner, ex, parent, or housemate is common, and it is +handled differently — the safety plan comes before the paperwork, because +cutting off access is what escalates. + +- **National Domestic Violence Hotline** (US) — 1-800-799-7233, TTY + 1-800-787-3224, or text START to 88788 — thehotline.org +- **NNEDV Safety Net** — techsafety.org — specialists in technology-facilitated + abuse, including financial abuse and coerced debt +- **Coalition Against Stalkerware** — stopstalkerware.org — international + service directory +- **SPARC** (stalking) — stalkingawareness.org +- **Operation Safe Escape** — safeescape.org — security help for people leaving + abusive situations +- **UK** — National Domestic Abuse Helpline 0808 2000 247; Refuge tech abuse + team at refugetechsafety.org +- **Australia** — 1800RESPECT, 1800 737 732 + +Coerced debt — accounts opened under threat or without meaningful consent — is +recognised by a growing number of states and by the CFPB. Say those words to +the advocate; it changes which route is available to you. + +## Step 6: Legal help, mostly free + +- **LawHelp.org** — free and low-cost legal aid by state +- **National Association of Consumer Advocates** — consumeradvocates.org — + lawyers who specialise in credit reporting and debt collection, many working + on contingency +- Your state or local **bar association referral service** +- Law school **consumer law clinics**, which often take identity theft cases + +Under the US Fair Credit Reporting Act, a consumer who wins a case can recover +legal fees from the other side, which is why many of these lawyers take cases +without charging up front. + +## Step 7: Outside the US, use these national channels + +- **UK** — Action Fraud, actionfraud.police.uk, 0300 123 2040. Cifas Protective + Registration, cifas.org.uk. Cyber incidents: ncsc.gov.uk/report. +- **Canada** — Canadian Anti-Fraud Centre, antifraudcentre.ca, 1-888-495-8501. + Equifax Canada 1-800-465-7166, TransUnion Canada 1-800-663-9980. +- **Australia** — IDCARE (above), ReportCyber at cyber.gov.au, Scamwatch. + Credit bans with Equifax, Experian, and illion. +- **New Zealand** — CERT NZ, Netsafe 0508 638 723, IDCARE NZ. +- **Ireland** — report to An Garda Síochána; the Central Bank handles + complaints about financial institutions. +- **EU/EEA** — national police for the theft, and your national data protection + authority if a company's breach caused it. Banking ombudsmen handle disputed + transactions. + +## Step 8: Keep learning, and check what is already exposed + +- **haveibeenpwned.com** — which breaches include your email addresses +- **EFF Surveillance Self-Defense** — ssd.eff.org — plain-language security + guides +- **Consumer Reports Security Planner** — + securityplanner.consumerreports.org — a personalised checklist, free +- **CISA** (US) — cisa.gov/report — 1-844-729-2472 — for compromise of systems + rather than personal identity +- **Citizen Lab** and **Amnesty International Security Lab** — for people who + may be targeted by state-grade or mercenary spyware + +Add the two or three numbers you actually used to your recovery log. In eight +months, when something reappears, the number you already know works is worth +more than another search. diff --git a/modules/bloatware/login_items/data/known_bloatware.json b/modules/bloatware/login_items/data/known_bloatware.json index a94fb5a..3acfb10 100644 --- a/modules/bloatware/login_items/data/known_bloatware.json +++ b/modules/bloatware/login_items/data/known_bloatware.json @@ -33,5 +33,45 @@ "name_pattern": "GoToMeeting", "name": "GoToMeeting", "description": "GoToMeeting background helper that launches at login even when not in a meeting." + }, + { + "name_pattern": "MacKeeper", + "name": "MacKeeper", + "description": "Scareware that invents system problems to sell a licence. It sets itself to start at login so it can reinstall itself after removal; remove the login item as well as the application." + }, + { + "name_pattern": "Advanced Mac Cleaner", + "name": "Advanced Mac Cleaner", + "description": "Fake cleaning utility that reports invented problems to sell a licence, and restarts itself at every login." + }, + { + "name_pattern": "Mac Auto Fixer", + "name": "Mac Auto Fixer", + "description": "Fake repair utility distributed by bundled installers; it starts at login to keep showing warnings." + }, + { + "name_pattern": "MacBooster", + "name": "MacBooster", + "description": "Fake performance optimiser that exaggerates system issues to sell upgrades." + }, + { + "name_pattern": "Genieo", + "name": "Genieo", + "description": "Adware that hijacks the browser homepage and search engine and restores itself at login." + }, + { + "name_pattern": "SearchMine", + "name": "SearchMine", + "description": "Browser hijacker that redirects searches and reinstates itself after being removed." + }, + { + "name_pattern": "TeamViewer", + "name": "TeamViewer", + "description": "Remote control software set to start at login. That is expected if it is deliberately used for remote support, and a serious problem if nobody knows why it is there — it lets whoever holds its credentials watch and control this Mac." + }, + { + "name_pattern": "AnyDesk", + "name": "AnyDesk", + "description": "Remote control software set to start at login. It is frequently left behind after 'tech support' phone scams; if you do not know why it is installed, treat it as unauthorised remote access." } ] diff --git a/modules/bloatware/process_scanner/__init__.py b/modules/bloatware/process_scanner/__init__.py index 436d25f..884d727 100644 --- a/modules/bloatware/process_scanner/__init__.py +++ b/modules/bloatware/process_scanner/__init__.py @@ -17,7 +17,11 @@ from rescue.runtime import content_file DATA_FILE = content_file("modules/bloatware/process_scanner/data/known_bloatware.json") -CRITICAL_CATEGORIES = {"scareware"} +# Categories where the software's own purpose is the harm: scareware extracts +# money through invented problems, and a miner spends the owner's electricity +# on someone else's behalf. Adware and bundled programs are a nuisance and are +# reported as warnings. +CRITICAL_CATEGORIES = {"scareware", "cryptominer"} class Module(ModuleBase): diff --git a/modules/bloatware/process_scanner/data/known_bloatware.json b/modules/bloatware/process_scanner/data/known_bloatware.json index 53bcbfe..52e5a66 100644 --- a/modules/bloatware/process_scanner/data/known_bloatware.json +++ b/modules/bloatware/process_scanner/data/known_bloatware.json @@ -5,5 +5,36 @@ {"process_pattern": "Advanced Mac Cleaner", "name": "Advanced Mac Cleaner", "category": "scareware", "description": "Fake system cleaner that pressures users into paying for a license to fix invented problems."}, {"process_pattern": "SearchProtect", "name": "Conduit SearchProtect", "category": "adware", "description": "Browser hijacker that locks in a hijacked search engine and homepage."}, {"process_pattern": "MacBooster", "name": "MacBooster", "category": "scareware", "description": "Fake performance-optimization utility that exaggerates system issues to sell upgrades."}, - {"process_pattern": "Genio", "name": "Genio", "category": "adware", "description": "Adware variant that injects ads into browser sessions."} + {"process_pattern": "Genio", "name": "Genio", "category": "adware", "description": "Adware variant that injects ads into browser sessions."}, + {"process_pattern": "Pirrit", "name": "Pirrit", "category": "adware", "description": "macOS adware family that injects advertising into web pages and installs a persistent background agent to reinstall itself."}, + {"process_pattern": "Bundlore", "name": "Bundlore", "category": "adware", "description": "macOS adware dropper distributed inside fake Flash Player and video-codec installers; it installs further adware and browser extensions."}, + {"process_pattern": "AdLoad", "name": "AdLoad", "category": "adware", "description": "macOS adware that installs a persistent agent and a local network proxy so it can insert advertising into browsing sessions."}, + {"process_pattern": "Shlayer", "name": "Shlayer", "category": "adware", "description": "Widespread macOS malware family that arrives as a fake Flash update and installs whichever adware pays its operators the most."}, + {"process_pattern": "Crossrider", "name": "Crossrider", "category": "adware", "description": "Adware development platform used to build browser hijackers that change the homepage and search engine."}, + {"process_pattern": "VSearch", "name": "VSearch", "category": "adware", "description": "macOS adware that redirects searches and installs a launch daemon so it survives removal."}, + {"process_pattern": "Mac Auto Fixer", "name": "Mac Auto Fixer", "category": "scareware", "description": "Fake repair utility that reports invented errors to sell a licence."}, + {"process_pattern": "Mac Adware Cleaner", "name": "Mac Adware Cleaner", "category": "scareware", "description": "Fake cleaning utility that is itself adware."}, + {"process_pattern": "Segurazo", "name": "Segurazo / SAntivirus", "category": "scareware", "description": "Rogue antivirus that installs without clear consent, reports invented threats, and resists uninstallation."}, + {"process_pattern": "Reimage", "name": "Reimage Repair", "category": "scareware", "description": "System repair product advertised through fake error pages and support-scam popups."}, + {"process_pattern": "Restoro", "name": "Restoro", "category": "scareware", "description": "PC repair product distributed through scare-tactic advertising and fake virus warnings."}, + {"process_pattern": "PC Accelerate", "name": "PC Accelerate", "category": "scareware", "description": "Bundled optimiser that reports invented problems to sell a licence."}, + {"process_pattern": "OneSafe PC Cleaner", "name": "OneSafe PC Cleaner", "category": "scareware", "description": "Fake system cleaner distributed by bundled installers."}, + {"process_pattern": "DriverPack", "name": "DriverPack Solution", "category": "pup", "description": "Driver installer that bundles unrelated software and changes browser settings."}, + {"process_pattern": "Wajam", "name": "Wajam", "category": "adware", "description": "Ad-injecting proxy that installs its own root certificate so it can modify encrypted web traffic."}, + {"process_pattern": "OpenCandy", "name": "OpenCandy", "category": "pup", "description": "Installer bundling framework that adds unrelated software during setup."}, + {"process_pattern": "InstallCore", "name": "InstallCore", "category": "pup", "description": "Installer bundling framework used to distribute adware and browser hijackers."}, + {"process_pattern": "Mindspark", "name": "Mindspark toolbars", "category": "adware", "description": "Toolbar family (MyWebSearch and relatives) that hijacks search and new-tab pages."}, + {"process_pattern": "MyWebSearch", "name": "MyWebSearch", "category": "adware", "description": "Search toolbar that replaces the browser's default search provider."}, + {"process_pattern": "Trovi", "name": "Trovi Search", "category": "adware", "description": "Browser hijacker that redirects searches through its own network."}, + {"process_pattern": "Vosteran", "name": "Vosteran", "category": "adware", "description": "Browser hijacker that locks the homepage and search engine."}, + {"process_pattern": "Delta Search", "name": "Delta Search", "category": "adware", "description": "Search hijacker installed alongside free software."}, + {"process_pattern": "WebDiscover", "name": "WebDiscover Browser", "category": "adware", "description": "Bundled browser that installs itself as the default and displays a persistent search bar."}, + {"process_pattern": "SearchMine", "name": "SearchMine", "category": "adware", "description": "macOS browser hijacker that redirects searches and reinstalls itself from a launch agent."}, + {"process_pattern": "Spigot", "name": "Spigot", "category": "adware", "description": "Adware family that installs browser extensions and changes search settings."}, + {"process_pattern": "Yontoo", "name": "Yontoo", "category": "adware", "description": "Ad-injection browser plugin installed by bundled setup programs."}, + {"process_pattern": "WildTangent", "name": "WildTangent Games", "category": "pup", "description": "Pre-installed game service that runs background updaters and displays advertising."}, + {"process_pattern": "xmrig", "name": "XMRig cryptocurrency miner", "category": "cryptominer", "description": "Cryptocurrency miner that spends this machine's processor time earning currency for whoever installed it. On a computer nobody deliberately set up for mining, it is stealing electricity and hardware life."}, + {"process_pattern": "mshelper", "name": "mshelper miner", "category": "cryptominer", "description": "macOS mining payload installed by fake Flash updates; pins a processor core at 100% and drains the battery."}, + {"process_pattern": "kdevtmpfsi", "name": "kdevtmpfsi miner", "category": "cryptominer", "description": "Linux cryptocurrency miner dropped by the Kinsing malware. The name imitates a kernel thread, but real kernel threads never run from /tmp."}, + {"process_pattern": "minergate", "name": "MinerGate", "category": "cryptominer", "description": "Mining client frequently bundled into cracked software and game modifications."} ] diff --git a/modules/bloatware/startup_auditor/data/known_bloatware.json b/modules/bloatware/startup_auditor/data/known_bloatware.json index 5d408bc..5dcbf0a 100644 --- a/modules/bloatware/startup_auditor/data/known_bloatware.json +++ b/modules/bloatware/startup_auditor/data/known_bloatware.json @@ -33,5 +33,60 @@ "label_pattern": "com.spotify.webhelper", "name": "Spotify Helper", "description": "Spotify background helper that keeps the app ready to launch instantly; disable if you don't need instant startup." + }, + { + "label_pattern": "com.zeobit.MacKeeper", + "name": "MacKeeper agent", + "description": "MacKeeper's background agent. MacKeeper is scareware: it invents system problems to sell a licence, and its agent reinstalls the app after it is dragged to the Trash. Remove the agent as well as the application." + }, + { + "label_pattern": "com.mackeeper", + "name": "MacKeeper helper", + "description": "MacKeeper background helper that restarts the scareware application after removal." + }, + { + "label_pattern": "com.pcv.hlpramc", + "name": "Advanced Mac Cleaner agent", + "description": "Persistence agent for Advanced Mac Cleaner, a fake cleaning utility that reports invented problems to sell a licence." + }, + { + "label_pattern": "com.genieoinnovation", + "name": "Genieo agent", + "description": "Genieo adware agent. It hijacks the browser's homepage and search engine and reinstalls itself at every login." + }, + { + "label_pattern": "com.genieo", + "name": "Genieo helper", + "description": "Genieo adware helper that restores the hijacked search settings after they are changed back." + }, + { + "label_pattern": "com.searchmine", + "name": "SearchMine agent", + "description": "Browser hijacker agent that redirects searches through SearchMine and reinstates itself after removal." + }, + { + "label_pattern": "com.spigot", + "name": "Spigot adware agent", + "description": "Spigot adware agent that installs browser extensions and changes search settings." + }, + { + "label_pattern": "com.vsearch", + "name": "VSearch agent", + "description": "VSearch adware agent that redirects searches and reinstalls its payload." + }, + { + "label_pattern": "com.pirrit", + "name": "Pirrit adware agent", + "description": "Pirrit adware agent. Pirrit injects advertising into web pages and keeps a background agent specifically so it can rebuild itself." + }, + { + "label_pattern": "com.adobe.fpsaud", + "name": "Adobe Flash Player update service", + "description": "Flash Player's update service. Flash reached end of life in 2020 and is no longer supported, so an agent still running under this name is either a leftover or adware impersonating Flash — fake Flash updaters are the most common macOS adware delivery method." + }, + { + "label_pattern": "com.avast", + "name": "Avast background agent", + "description": "Avast background service. It is functional antivirus software, but it also runs several always-on agents and has a history of monetising browsing data; keep it only if it is deliberately in use." } ] diff --git a/modules/bloatware/win_bloatware/data/known_bloatware.json b/modules/bloatware/win_bloatware/data/known_bloatware.json index 9ef0997..7e4ce8f 100644 --- a/modules/bloatware/win_bloatware/data/known_bloatware.json +++ b/modules/bloatware/win_bloatware/data/known_bloatware.json @@ -117,5 +117,82 @@ "app_pattern": "feedback", "description": "Windows Feedback Hub for sending system telemetry.", "estimated_resource_savings": "150 MB disk space" + }, + { + "name": "McAfee LiveSafe trial", + "publisher_pattern": "McAfee", + "app_pattern": "mcafee", + "description": "Pre-installed antivirus trial. Once the trial expires it stops updating but keeps Microsoft Defender switched off, which leaves the machine less protected than having no third-party antivirus at all. Uninstall it with McAfee's own removal tool, then confirm Defender turned itself back on.", + "estimated_resource_savings": "1 GB disk space, and Microsoft Defender re-enables itself" + }, + { + "name": "Norton Security trial", + "publisher_pattern": "NortonLifeLock", + "app_pattern": "norton", + "description": "Pre-installed antivirus trial that disables Microsoft Defender while it is present and stops receiving updates when the trial ends. Recent versions also ship a cryptocurrency miner feature, which is opt-in but installed regardless.", + "estimated_resource_savings": "1 GB disk space, and Microsoft Defender re-enables itself" + }, + { + "name": "WildTangent Games", + "publisher_pattern": "WildTangent", + "app_pattern": "wildtangent", + "description": "Pre-installed game service that runs a background updater and shows advertising.", + "estimated_resource_savings": "500 MB disk space, one background process" + }, + { + "name": "Booking.com", + "publisher_pattern": "Booking.com", + "app_pattern": "booking", + "description": "Pre-installed advertising app placed on many consumer laptops; it is a shortcut to a website.", + "estimated_resource_savings": "50 MB disk space" + }, + { + "name": "Candy Crush Friends Saga", + "publisher_pattern": "king", + "app_pattern": "candycrushfriends", + "description": "Additional pre-installed King game variant.", + "estimated_resource_savings": "400 MB disk space" + }, + { + "name": "Cooking Fever", + "publisher_pattern": "Nordcurrent", + "app_pattern": "cookingfever", + "description": "Pre-installed mobile game.", + "estimated_resource_savings": "300 MB disk space" + }, + { + "name": "Hidden City", + "publisher_pattern": "G5 Entertainment", + "app_pattern": "hiddencity", + "description": "Pre-installed mobile game.", + "estimated_resource_savings": "400 MB disk space" + }, + { + "name": "Dolby Access", + "publisher_pattern": "Dolby", + "app_pattern": "dolbyaccess", + "description": "Trial app for Dolby Atmos; the feature it unlocks is a paid add-on.", + "estimated_resource_savings": "200 MB disk space" + }, + { + "name": "PC App Store", + "publisher_pattern": "PC App Store", + "app_pattern": "pcappstore", + "description": "Third-party software store bundled by installers; it installs further software and advertising without clear consent.", + "estimated_resource_savings": "300 MB disk space, one background process" + }, + { + "name": "Driver Booster", + "publisher_pattern": "IObit", + "app_pattern": "driverbooster", + "description": "Driver updater that reports invented out-of-date drivers to sell an upgrade, and can install drivers Windows Update would not. Windows Update already handles drivers on supported systems.", + "estimated_resource_savings": "250 MB disk space, one background process" + }, + { + "name": "Advanced SystemCare", + "publisher_pattern": "IObit", + "app_pattern": "advancedsystemcare", + "description": "System optimiser that reports large numbers of low-impact issues to prompt an upgrade purchase, and installs several background services.", + "estimated_resource_savings": "400 MB disk space, several background processes" } ] diff --git a/modules/integrity/win_bsod_analysis/__init__.py b/modules/integrity/win_bsod_analysis/__init__.py index e21f326..ba1d379 100644 --- a/modules/integrity/win_bsod_analysis/__init__.py +++ b/modules/integrity/win_bsod_analysis/__init__.py @@ -125,13 +125,19 @@ def check(self, profile: SystemProfile) -> CheckResult: ) ) - # INFO: BSOD history with details + # INFO: BSOD history with details. + # Built outside the f-string: nesting the same quote character inside an + # f-string expression is a syntax error before Python 3.12, and this + # package supports 3.11. + stop_code_summary = ", ".join( + f"{code} ({self.STOP_CODES.get(code, 'Unknown')})" for code in stop_codes + ) findings.append( Finding( title=f"BSOD history ({event_count} events)", description=( f"Found {event_count} Blue Screen of Death event(s) in event log. " - f"Stop codes: {', '.join(f'{code} ({self.STOP_CODES.get(code, 'Unknown')})' for code in stop_codes.keys())}. " + f"Stop codes: {stop_code_summary}. " f"Minidump files: {'present' if minidump_exists else 'not found'}. " "Review the event log for patterns and driver/hardware issues." ), diff --git a/modules/network/arp_spoof_check/__init__.py b/modules/network/arp_spoof_check/__init__.py new file mode 100644 index 0000000..00e51f8 --- /dev/null +++ b/modules/network/arp_spoof_check/__init__.py @@ -0,0 +1,318 @@ +"""Detect ARP spoofing — someone on the network intercepting your traffic. + +Getting onto a Wi-Fi network is the first step; the reason to bother is +usually the second one, which is to sit between you and the router and read +what you send. That attack (ARP spoofing, ARP poisoning, "man in the middle") +leaves a specific fingerprint in the neighbour table: the attacker's hardware +address starts answering for IP addresses that are not theirs, most often the +router's. + +This module compares the machine's own view of the network against what a +healthy network looks like. It cannot see attacks that leave no local trace, +so a clean result means "nothing visible from here", not "nobody is watching". +""" + +from collections import defaultdict + +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase +from rescue.runtime import content_directory, load_content_module + +_LAN_COMMON_DIR = content_directory("modules/network/lan_common") +_NEIGHBORS_KEY = "rescue_lan_common_neighbors" + +# One hardware address answering for several IPs is normal for a router doing +# proxy ARP or a host with several addresses; it becomes interesting at three. +_DUPLICATE_IP_THRESHOLD = 3 + +_CLEAN_RESULT_CAVEAT = ( + "A clean result here means no interception is visible from this computer. " + "An attacker who only targets other devices on the network, or who " + "intercepts traffic at the router itself, would not show up in this check." +) + + +def _neighbors_module(): + return load_content_module("modules/network/lan_common/neighbors.py", _NEIGHBORS_KEY) + + +class Module(ModuleBase): + name = "arp_spoof_check" + category = "network" + platforms = [Platform.DARWIN, Platform.WIN32, Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 80 + depends_on = [] + estimated_duration = "10s" + + def check(self, profile: SystemProfile) -> CheckResult: + neighbors = _neighbors_module() + if neighbors is None: + return CheckResult( + module_name=self.name, + error="Local-network helper data could not be loaded.", + ) + + platform = profile.platform.value + entries = neighbors.read_neighbors(platform) + gateways = neighbors.read_gateways(platform) + + if not entries: + return CheckResult( + module_name=self.name, + error=( + "The neighbour table could not be read, or is empty. Check that " + "this machine is connected to the network." + ), + ) + + gateway_ips = {gw.ip for gw in gateways} + by_mac: dict[str, list[str]] = defaultdict(list) + for entry in entries: + if entry.ip not in by_mac[entry.mac]: + by_mac[entry.mac].append(entry.ip) + + findings: list[Finding] = [] + findings.extend(self._check_gateway_impersonation(by_mac, gateway_ips)) + findings.extend(self._check_duplicate_macs(by_mac, gateway_ips)) + findings.extend(self._check_gateway_mac(entries, gateway_ips, neighbors)) + findings.extend(self._check_multiple_gateways(gateways)) + return CheckResult(module_name=self.name, findings=findings) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + """Guidance only. Interception is an emergency, not a cleanup task.""" + actions: list[Action] = [] + if not findings.findings: + return FixResult(module_name=self.name, actions=actions) + + interception = [ + f + for f in findings.findings + if f.data.get("check") + in {"gateway_impersonation", "duplicate_mac", "multiple_gateways"} + ] + + if interception: + actions.append( + Action( + title="Stop using this network for anything sensitive right now", + description=( + "Something on this network is answering for addresses that are " + "not its own, which is how traffic gets intercepted. Until it " + "is resolved:\n" + "- Do not sign in to anything, and do not change any passwords " + "while connected to this network.\n" + "- Switch to a phone's mobile hotspot for anything that " + "matters. Mobile data is not on the compromised network.\n" + "- If you must stay connected, a VPN encrypts what an " + "interceptor can read, but it does not remove them from the " + "network." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + actions.append( + Action( + title="Confirm the router's real hardware address", + description=( + "Look at the label on the underside of the router, or its " + "admin page, and compare the hardware (MAC) address printed " + "there with the address reported above. If they differ, " + "another device is impersonating the router and everything " + "you send is passing through it first.\n\n" + "One caution: some mesh systems and Wi-Fi extenders legitimately " + "answer for the router. If the address belongs to your own " + "extender, that explains the result." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + actions.append( + Action( + title="Evict the intruder and rebuild the network's credentials", + description=( + "An interceptor is already on the network, so the Wi-Fi " + "password is compromised. In this order:\n" + "1. Connect a computer to the router by cable if you can, so " + "the next steps do not go over the air.\n" + "2. Change the router's admin password.\n" + "3. Change the Wi-Fi password to a new long passphrase and set " + "the security mode to WPA3, or WPA2-AES if WPA3 is unavailable.\n" + "4. Turn off WPS — it lets a device join without the password.\n" + "5. Install any pending router firmware update.\n" + "6. Reconnect your devices and re-run this check.\n\n" + "Then change the passwords of accounts you used while the " + "interception was active, starting with email. Do that from a " + "device on a different network." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + else: + actions.append( + Action( + title="Review the flagged hardware address", + description=( + "The result above is unusual but has innocent explanations — " + "mesh nodes, powerline adapters, and virtual machines can all " + "produce it. Identify the device before treating it as an " + f"attack. {_CLEAN_RESULT_CAVEAT}" + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + + return FixResult(module_name=self.name, actions=actions) + + # ---------------- checks ---------------- + + def _check_gateway_impersonation( + self, by_mac: dict[str, list[str]], gateway_ips: set[str] + ) -> list[Finding]: + """The strongest signal: the router's MAC also answering for other IPs.""" + findings = [] + for mac, ips in by_mac.items(): + gateway_matches = [ip for ip in ips if ip in gateway_ips] + others = [ip for ip in ips if ip not in gateway_ips] + if not gateway_matches or not others: + continue + findings.append( + Finding( + title="Your router's hardware address is answering for other devices", + description=( + f"The hardware address {mac} belongs to your router " + f"({', '.join(gateway_matches)}) but is also claiming to be " + f"{', '.join(others[:8])}. On a healthy network each device has " + "its own address. This pattern is what traffic interception " + "looks like: one machine has positioned itself between you and " + "everything else so it can read what you send.\n\n" + "A Wi-Fi extender or mesh node repeating the network can produce " + "the same pattern legitimately — confirm which it is before " + "acting." + ), + severity=Severity.CRITICAL, + category=self.category, + data={ + "check": "gateway_impersonation", + "mac": mac, + "gateway_ips": gateway_matches, + "other_ips": others, + "confidence": "medium", + }, + ) + ) + return findings + + def _check_duplicate_macs( + self, by_mac: dict[str, list[str]], gateway_ips: set[str] + ) -> list[Finding]: + findings = [] + for mac, ips in by_mac.items(): + if any(ip in gateway_ips for ip in ips): + continue # already reported, with more context, above + if len(ips) < _DUPLICATE_IP_THRESHOLD: + continue + findings.append( + Finding( + title=f"One device is answering for {len(ips)} addresses", + description=( + f"The hardware address {mac} is claiming {len(ips)} different " + f"IP addresses ({', '.join(ips[:8])}). A single device " + "answering for many addresses is how an interceptor makes " + "other devices' traffic come to it. Virtual machines, " + "container hosts, and some network gear do this legitimately, " + "so identify the device before concluding anything." + ), + severity=Severity.WARNING, + category=self.category, + data={ + "check": "duplicate_mac", + "mac": mac, + "ips": ips, + "confidence": "low", + }, + ) + ) + return findings + + def _check_gateway_mac( + self, entries: list, gateway_ips: set[str], neighbors + ) -> list[Finding]: + """A router with a software-invented hardware address is suspicious.""" + findings = [] + reported: set[str] = set() + for entry in entries: + if entry.ip not in gateway_ips or entry.mac in reported: + continue + if not entry.is_locally_administered: + continue + reported.add(entry.mac) + findings.append( + Finding( + title="The router's hardware address looks software-generated", + description=( + f"The gateway at {entry.ip} reports the hardware address " + f"{entry.mac}. That address is marked as locally administered, " + "meaning it was chosen by software rather than assigned to the " + "hardware by its manufacturer. Real routers use a " + "manufacturer-assigned address; a made-up one suggests " + "something is impersonating the router.\n\n" + "Virtual networks, some corporate equipment, and a few mesh " + "systems also use locally administered addresses, so check the " + "address on the router's label before acting." + ), + severity=Severity.WARNING, + category=self.category, + data={ + "check": "locally_administered_gateway", + "mac": entry.mac, + "ip": entry.ip, + "confidence": "low", + }, + ) + ) + return findings + + def _check_multiple_gateways(self, gateways: list) -> list[Finding]: + unique = {gw.ip for gw in gateways if gw.ip} + if len(unique) < 2: + return [] + return [ + Finding( + title=f"This machine has {len(unique)} default gateways configured", + description=( + "More than one default route is configured: " + f"{', '.join(sorted(unique))}. Traffic can leave through either, " + "so a rogue router or a rogue DHCP server on the network can take " + "over part of your traffic without anything appearing to break. " + "A VPN client or a virtual machine bridge can also add a second " + "gateway legitimately." + ), + severity=Severity.WARNING, + category=self.category, + data={ + "check": "multiple_gateways", + "gateways": sorted(unique), + "confidence": "medium", + }, + ) + ] diff --git a/modules/network/lan_common/__init__.py b/modules/network/lan_common/__init__.py new file mode 100644 index 0000000..b5b088f --- /dev/null +++ b/modules/network/lan_common/__init__.py @@ -0,0 +1,5 @@ +"""Data-only helper package shared by the local-network modules. + +Contains no ``Module`` class, so ``rescue.registry.discover_modules`` skips it. +``neighbors.py`` is loaded by path via ``rescue.runtime.load_content_module``. +""" diff --git a/modules/network/lan_common/neighbors.py b/modules/network/lan_common/neighbors.py new file mode 100644 index 0000000..4649bb8 --- /dev/null +++ b/modules/network/lan_common/neighbors.py @@ -0,0 +1,253 @@ +"""Read the local machine's view of its network neighbours. + +Every device that has recently talked to this machine leaves an entry in the +ARP/neighbour table: an IP address and the hardware (MAC) address behind it. +That table is the only view of "who else is on this Wi-Fi" available without +scanning, and it is what both the LAN inventory and the ARP-spoofing check are +built on. + +Read-only, bounded, and platform-aware. Every command has a timeout and every +parse failure degrades to an empty result rather than raising. +""" + +from __future__ import annotations + +import json +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + +_COMMAND_TIMEOUT = 15 + +# A table larger than this on a home network means something is wrong with the +# parse, not that 512 devices are present; cap it either way. +_MAX_NEIGHBORS = 512 + +_MAC_PATTERN = re.compile(r"\b([0-9a-fA-F]{1,2}(?:[:-][0-9a-fA-F]{1,2}){5})\b") +_IPV4_PATTERN = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b") + +# Placeholder entries the OS keeps for unresolved or broadcast addresses. +_IGNORED_MACS = { + "00:00:00:00:00:00", + "ff:ff:ff:ff:ff:ff", +} + + +@dataclass(frozen=True) +class Neighbor: + ip: str + mac: str + interface: str + + @property + def is_multicast(self) -> bool: + """True for IPv4 multicast/broadcast MAC mappings, which are not devices.""" + return self.mac.startswith("01:00:5e") or self.mac.startswith("33:33") + + @property + def is_locally_administered(self) -> bool: + """True when the MAC's 'locally administered' bit is set. + + Phones set this bit deliberately for Wi-Fi privacy (randomised MACs), + so on its own it is normal. On a *router's* MAC it is not: routers ship + with a vendor-assigned address, and a locally administered one there + suggests the address was made up by software. + """ + try: + first_octet = int(self.mac.split(":")[0], 16) + except (ValueError, IndexError): + return False + return bool(first_octet & 0b10) + + +@dataclass(frozen=True) +class Gateway: + ip: str + interface: str + + +def read_neighbors(platform: str) -> list[Neighbor]: + """Return the ARP/neighbour table, normalised across platforms.""" + if platform == "linux": + output = _run(["ip", "neigh", "show"]) + neighbors = _parse_ip_neigh(output) + if neighbors: + return neighbors + return _parse_bsd_arp(_run(["arp", "-an"])) + if platform == "darwin": + return _parse_bsd_arp(_run(["arp", "-a", "-n"])) + if platform == "win32": + return _parse_windows_arp(_run(["arp", "-a"])) + return [] + + +def read_gateways(platform: str) -> list[Gateway]: + """Return the configured default gateway(s).""" + if platform == "linux": + return _parse_ip_route(_run(["ip", "route", "show", "default"])) + if platform == "darwin": + return _parse_darwin_route(_run(["route", "-n", "get", "default"])) + if platform == "win32": + return _parse_windows_route(_run(["route", "print", "-4"])) + return [] + + +def normalise_mac(value: str) -> str: + """Return a lower-case colon-separated MAC with zero-padded octets. + + BSD `arp` prints `0:1c:42:0:0:8`, Windows prints `00-1C-42-00-00-08`, and + Linux prints `00:1c:42:00:00:08`; all three must compare equal. + """ + parts = re.split(r"[:-]", value.strip()) + if len(parts) != 6: + return value.strip().lower() + try: + return ":".join(f"{int(part, 16):02x}" for part in parts) + except ValueError: + return value.strip().lower() + + +def load_oui_vendors(data_dir: Path | None = None) -> dict[str, str]: + """Return a mapping of OUI prefix (``aa:bb:cc``) to vendor name.""" + if data_dir is None: + data_dir = Path(__file__).parent + path = data_dir / "oui_vendors.json" + try: + if not path.exists(): + return {} + with open(path, "r") as f: + data = json.load(f) + except (OSError, ValueError): + return {} + entries = data.get("entries", {}) + if not isinstance(entries, dict): + return {} + return {str(k).lower(): str(v) for k, v in entries.items()} + + +def vendor_for(mac: str, vendors: dict[str, str]) -> str | None: + """Return the vendor that registered this MAC's prefix, if known.""" + prefix = ":".join(mac.split(":")[:3]).lower() + return vendors.get(prefix) + + +# ---------------- parsers ---------------- + + +def _parse_ip_neigh(output: str) -> list[Neighbor]: + neighbors: list[Neighbor] = [] + for line in output.splitlines(): + parts = line.split() + if len(parts) < 2 or "lladdr" not in parts: + continue + ip = parts[0] + if not _IPV4_PATTERN.fullmatch(ip): + continue + mac = normalise_mac(parts[parts.index("lladdr") + 1]) + interface = parts[parts.index("dev") + 1] if "dev" in parts else "" + neighbors.append(Neighbor(ip=ip, mac=mac, interface=interface)) + if len(neighbors) >= _MAX_NEIGHBORS: + break + return [n for n in neighbors if _is_real(n)] + + +def _parse_bsd_arp(output: str) -> list[Neighbor]: + """Parse `? (192.168.1.1) at 0:1c:42:0:0:8 on en0 ifscope [ethernet]`.""" + neighbors: list[Neighbor] = [] + for line in output.splitlines(): + ip_match = re.search(r"\((\d{1,3}(?:\.\d{1,3}){3})\)", line) + mac_match = _MAC_PATTERN.search(line) + if ip_match is None or mac_match is None: + continue + interface_match = re.search(r"\bon\s+(\S+)", line) + neighbors.append( + Neighbor( + ip=ip_match.group(1), + mac=normalise_mac(mac_match.group(1)), + interface=interface_match.group(1) if interface_match else "", + ) + ) + if len(neighbors) >= _MAX_NEIGHBORS: + break + return [n for n in neighbors if _is_real(n)] + + +def _parse_windows_arp(output: str) -> list[Neighbor]: + """Parse `arp -a`, which groups rows under `Interface: --- 0x5`.""" + neighbors: list[Neighbor] = [] + interface = "" + for line in output.splitlines(): + stripped = line.strip() + if stripped.lower().startswith("interface:"): + match = _IPV4_PATTERN.search(stripped) + interface = match.group(1) if match else "" + continue + ip_match = _IPV4_PATTERN.search(stripped) + mac_match = _MAC_PATTERN.search(stripped) + if ip_match is None or mac_match is None: + continue + neighbors.append( + Neighbor( + ip=ip_match.group(1), + mac=normalise_mac(mac_match.group(1)), + interface=interface, + ) + ) + if len(neighbors) >= _MAX_NEIGHBORS: + break + return [n for n in neighbors if _is_real(n)] + + +def _parse_ip_route(output: str) -> list[Gateway]: + gateways: list[Gateway] = [] + for line in output.splitlines(): + parts = line.split() + if "via" not in parts: + continue + ip = parts[parts.index("via") + 1] + interface = parts[parts.index("dev") + 1] if "dev" in parts else "" + gateways.append(Gateway(ip=ip, interface=interface)) + return gateways + + +def _parse_darwin_route(output: str) -> list[Gateway]: + gateway_ip = "" + interface = "" + for line in output.splitlines(): + stripped = line.strip() + if stripped.startswith("gateway:"): + gateway_ip = stripped.split(":", 1)[1].strip() + elif stripped.startswith("interface:"): + interface = stripped.split(":", 1)[1].strip() + return [Gateway(ip=gateway_ip, interface=interface)] if gateway_ip else [] + + +def _parse_windows_route(output: str) -> list[Gateway]: + """Parse the IPv4 default routes out of `route print -4`.""" + gateways: list[Gateway] = [] + seen: set[str] = set() + for line in output.splitlines(): + parts = line.split() + if len(parts) < 4 or parts[0] != "0.0.0.0" or parts[1] != "0.0.0.0": + continue + gateway_ip = parts[2] + if not _IPV4_PATTERN.fullmatch(gateway_ip) or gateway_ip in seen: + continue + seen.add(gateway_ip) + gateways.append(Gateway(ip=gateway_ip, interface=parts[3])) + return gateways + + +def _is_real(neighbor: Neighbor) -> bool: + return neighbor.mac not in _IGNORED_MACS and not neighbor.is_multicast + + +def _run(command: list[str]) -> str: + try: + result = subprocess.run( + command, capture_output=True, text=True, timeout=_COMMAND_TIMEOUT + ) + except (OSError, subprocess.SubprocessError): + return "" + return result.stdout or "" diff --git a/modules/network/lan_common/oui_vendors.json b/modules/network/lan_common/oui_vendors.json new file mode 100644 index 0000000..b37e3a9 --- /dev/null +++ b/modules/network/lan_common/oui_vendors.json @@ -0,0 +1,132 @@ +{ + "version": "1.0.0", + "description": "Small curated OUI-prefix to vendor map, used to make a list of MAC addresses legible to a non-technical person. Not exhaustive — an unknown prefix means 'not in this list', never 'suspicious'.", + "entries": { + "00:03:93": "Apple", + "00:05:02": "Apple", + "00:1b:63": "Apple", + "00:1e:c2": "Apple", + "00:25:00": "Apple", + "3c:15:c2": "Apple", + "40:cb:c0": "Apple", + "68:ab:1e": "Apple", + "a4:83:e7": "Apple", + "ac:bc:32": "Apple", + "f0:18:98": "Apple", + "f4:5c:89": "Apple", + "00:16:6c": "Samsung", + "00:1d:25": "Samsung", + "34:23:ba": "Samsung", + "5c:0a:5b": "Samsung", + "78:1f:db": "Samsung", + "8c:77:12": "Samsung", + "00:1a:11": "Google", + "3c:5a:b4": "Google", + "54:60:09": "Google", + "94:eb:2c": "Google", + "f4:f5:d8": "Google", + "00:fc:8b": "Amazon", + "34:d2:70": "Amazon", + "44:65:0d": "Amazon", + "68:37:e9": "Amazon", + "74:c2:46": "Amazon", + "f0:27:2d": "Amazon", + "00:1c:42": "Parallels (virtual machine)", + "00:0c:29": "VMware (virtual machine)", + "00:50:56": "VMware (virtual machine)", + "08:00:27": "VirtualBox (virtual machine)", + "52:54:00": "QEMU/KVM (virtual machine)", + "b8:27:eb": "Raspberry Pi", + "dc:a6:32": "Raspberry Pi", + "e4:5f:01": "Raspberry Pi", + "00:1d:7e": "Cisco-Linksys", + "00:23:69": "Cisco-Linksys", + "48:f8:b3": "Cisco-Linksys", + "14:cc:20": "TP-Link", + "50:c7:bf": "TP-Link", + "a4:2b:b0": "TP-Link", + "c0:06:c3": "TP-Link", + "00:09:5b": "Netgear", + "20:4e:7f": "Netgear", + "a0:40:a0": "Netgear", + "c0:3f:0e": "Netgear", + "00:14:6c": "Netgear", + "00:18:39": "Cisco", + "00:1a:a1": "Cisco", + "00:26:99": "Cisco", + "24:a4:3c": "Ubiquiti", + "78:8a:20": "Ubiquiti", + "fc:ec:da": "Ubiquiti", + "00:0d:b9": "PC Engines", + "00:11:32": "Synology (NAS)", + "00:1b:21": "Intel", + "00:1f:3c": "Intel", + "3c:97:0e": "Intel", + "8c:16:45": "Intel", + "e4:b3:18": "Intel", + "00:e0:4c": "Realtek", + "52:54:ab": "Realtek", + "18:fe:34": "Espressif (ESP8266/ESP32 smart-home device)", + "24:0a:c4": "Espressif (ESP8266/ESP32 smart-home device)", + "2c:3a:e8": "Espressif (ESP8266/ESP32 smart-home device)", + "84:f3:eb": "Espressif (ESP8266/ESP32 smart-home device)", + "b4:e6:2d": "Espressif (ESP8266/ESP32 smart-home device)", + "cc:50:e3": "Espressif (ESP8266/ESP32 smart-home device)", + "00:0d:4b": "Roku", + "b8:3e:59": "Roku", + "cc:6d:a0": "Roku", + "00:0e:58": "Sonos", + "5c:aa:fd": "Sonos", + "94:9f:3e": "Sonos", + "00:17:88": "Philips Hue", + "ec:b5:fa": "Philips Hue", + "18:b4:30": "Nest", + "64:16:66": "Nest", + "0c:47:c9": "Amazon (Ring)", + "54:e0:19": "Ring", + "2c:aa:8e": "Wyze", + "7c:78:b2": "Wyze", + "a4:da:22": "Wyze", + "28:57:be": "Hikvision (IP camera)", + "44:19:b6": "Hikvision (IP camera)", + "c0:56:e3": "Hikvision (IP camera)", + "3c:ef:8c": "Dahua (IP camera)", + "90:02:a9": "Dahua (IP camera)", + "00:1f:54": "Sony", + "00:24:be": "Sony", + "fc:0f:e6": "Sony (PlayStation)", + "00:17:fa": "Microsoft", + "28:18:78": "Microsoft", + "58:82:a8": "Microsoft (Xbox)", + "00:1e:8f": "Canon (printer)", + "00:00:48": "Epson (printer)", + "00:1b:a9": "Brother (printer)", + "00:21:5a": "HP (printer)", + "3c:d9:2b": "HP", + "00:24:81": "HP", + "00:16:35": "HP", + "d8:9e:f3": "Dell", + "f8:bc:12": "Dell", + "00:1e:65": "Intel (laptop Wi-Fi)", + "00:24:d7": "Intel (laptop Wi-Fi)", + "00:26:c7": "Intel (laptop Wi-Fi)", + "60:57:18": "Intel (laptop Wi-Fi)", + "00:1d:d8": "Microsoft (Hyper-V virtual machine)", + "00:15:5d": "Microsoft (Hyper-V virtual machine)", + "00:09:0f": "Fortinet", + "00:1c:23": "Dell", + "00:0f:b5": "Netgear", + "44:d9:e7": "Ubiquiti", + "80:2a:a8": "Ubiquiti", + "00:26:bb": "Apple", + "d0:e1:40": "Apple", + "9c:20:7b": "Apple", + "60:f8:1d": "Apple", + "b8:e8:56": "Apple", + "dc:2b:2a": "Apple", + "00:1c:b3": "Apple", + "70:56:81": "Apple", + "e0:ac:cb": "Apple", + "84:38:35": "Apple" + } +} diff --git a/modules/network/lan_device_inventory/__init__.py b/modules/network/lan_device_inventory/__init__.py new file mode 100644 index 0000000..8ce29eb --- /dev/null +++ b/modules/network/lan_device_inventory/__init__.py @@ -0,0 +1,284 @@ +"""List every device currently visible on the local network. + +When someone has got onto a home Wi-Fi network, the first question is always +"who is actually on it?" — and the honest answer is that most people have +never looked. This module reads the machine's ARP/neighbour table, labels each +hardware address with the vendor that registered it, and presents the result +as a list to be reconciled device by device. + +It deliberately does not scan or probe: it reports only devices that have +already exchanged traffic with this machine. That means the list can be +incomplete (a quiet device may not appear), which the findings say plainly +rather than implying the network has been fully enumerated. +""" + +from typing import Any + +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase +from rescue.runtime import content_directory, load_content_module + +_LAN_COMMON_DIR = content_directory("modules/network/lan_common") +_NEIGHBORS_KEY = "rescue_lan_common_neighbors" + +# Devices listed individually in the finding description before it is +# truncated; the complete list always stays in the finding's data. +_MAX_LISTED_DEVICES = 40 + +_INCOMPLETE_LIST_CAVEAT = ( + "This list only includes devices that have exchanged traffic with this " + "computer recently, so a device that stays quiet can be missing from it. " + "The router's own admin page has the authoritative list of everything " + "connected." +) + + +def _neighbors_module(): + return load_content_module("modules/network/lan_common/neighbors.py", _NEIGHBORS_KEY) + + +class Module(ModuleBase): + name = "lan_device_inventory" + category = "network" + platforms = [Platform.DARWIN, Platform.WIN32, Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 55 + depends_on = [] + estimated_duration = "10s" + + def __init__(self) -> None: + self._expected_device_count: int | None = None + self._known_macs: set[str] = set() + + def configure(self, config: dict[str, Any]) -> None: + """Accept an expected device count and a list of already-known MACs. + + Both come from a profile's ``module_config``. Without them the module + still produces the inventory; with them it can point at the specific + devices the household has not accounted for. + """ + expected = config.get("expected_device_count") + if isinstance(expected, int) and expected >= 0: + self._expected_device_count = expected + + known = config.get("known_macs") + if isinstance(known, (list, tuple)): + neighbors = _neighbors_module() + self._known_macs = { + neighbors.normalise_mac(str(mac)) if neighbors else str(mac).lower() + for mac in known + } + + def check(self, profile: SystemProfile) -> CheckResult: + neighbors = _neighbors_module() + if neighbors is None: + return CheckResult( + module_name=self.name, + error="Local-network helper data could not be loaded.", + ) + + entries = neighbors.read_neighbors(profile.platform.value) + if not entries: + return CheckResult( + module_name=self.name, + error=( + "The neighbour table could not be read, or is empty. Check that " + "this machine is connected to the network." + ), + ) + + gateway_ips = {gw.ip for gw in neighbors.read_gateways(profile.platform.value)} + vendors = neighbors.load_oui_vendors(_LAN_COMMON_DIR) + + devices = self._describe_devices(entries, gateway_ips, vendors, neighbors) + findings = [self._inventory_finding(devices)] + findings.extend(self._unaccounted_findings(devices)) + findings.extend(self._count_findings(devices)) + return CheckResult(module_name=self.name, findings=findings) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + """Guidance only — identifying a device is something only its owner can do.""" + actions: list[Action] = [] + if not findings.findings: + return FixResult(module_name=self.name, actions=actions) + + actions.append( + Action( + title="Account for every device on the list", + description=( + "Go through the list one device at a time and name it out loud: " + "phone, laptop, TV, printer, thermostat, games console, doorbell. " + "Anything left over at the end is the thing to worry about.\n\n" + "Two tips that save time:\n" + "- Turn a device off and re-run this check. The entry that " + "disappears is that device.\n" + "- Modern phones use a different, randomised hardware address for " + "each network, so a phone will not match the address printed on " + "the box. That is normal privacy behaviour, not an intruder.\n\n" + f"{_INCOMPLETE_LIST_CAVEAT}" + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + actions.append( + Action( + title="Remove unknown devices by changing the Wi-Fi password", + description=( + "Blocking one device by its hardware address does not work — an " + "address can be changed in seconds. The reliable way to remove " + "everyone you have not authorised is:\n" + "1. Change the Wi-Fi password on the router to a new, long " + "passphrase.\n" + "2. Change the router's admin password too, and make it different " + "from the Wi-Fi password.\n" + "3. Reconnect your own devices with the new password.\n" + "4. Re-run this check — anything still present after the change " + "either has the new password or is connected by cable.\n\n" + "If unknown devices come back after a password change, the router " + "itself is compromised. Factory reset it, update its firmware, and " + "set it up again from scratch." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + return FixResult(module_name=self.name, actions=actions) + + # ---------------- internals ---------------- + + def _describe_devices( + self, + entries: list, + gateway_ips: set[str], + vendors: dict[str, str], + neighbors, + ) -> list[dict]: + """Collapse the neighbour table into one record per hardware address.""" + by_mac: dict[str, dict] = {} + for entry in entries: + record = by_mac.get(entry.mac) + if record is None: + record = { + "mac": entry.mac, + "ips": [], + "interface": entry.interface, + "vendor": neighbors.vendor_for(entry.mac, vendors), + "is_gateway": False, + "randomised_mac": entry.is_locally_administered, + } + by_mac[entry.mac] = record + if entry.ip not in record["ips"]: + record["ips"].append(entry.ip) + if entry.ip in gateway_ips: + record["is_gateway"] = True + return sorted(by_mac.values(), key=lambda r: _ip_sort_key(r["ips"][0])) + + def _inventory_finding(self, devices: list[dict]) -> Finding: + lines = [] + for device in devices[:_MAX_LISTED_DEVICES]: + label = device["vendor"] or ( + "unknown vendor (randomised address)" + if device["randomised_mac"] + else "unknown vendor" + ) + role = " — your router" if device["is_gateway"] else "" + lines.append(f" {device['ips'][0]} {device['mac']} {label}{role}") + if len(devices) > _MAX_LISTED_DEVICES: + lines.append(f" … and {len(devices) - _MAX_LISTED_DEVICES} more") + + return Finding( + title=f"{len(devices)} device(s) visible on the local network", + description=( + "These devices have recently communicated with this computer over " + "the local network:\n" + "\n".join(lines) + "\n\n" + f"{_INCOMPLETE_LIST_CAVEAT}" + ), + severity=Severity.INFO, + category=self.category, + data={ + "check": "lan_inventory", + "device_count": len(devices), + "devices": devices, + "confidence": "high", + }, + ) + + def _unaccounted_findings(self, devices: list[dict]) -> list[Finding]: + if not self._known_macs: + return [] + findings = [] + for device in devices: + if device["mac"] in self._known_macs or device["is_gateway"]: + continue + vendor = device["vendor"] or "an unidentified vendor" + findings.append( + Finding( + title=f"Unrecognised device on the network: {device['ips'][0]}", + description=( + f"The device at {device['ips'][0]} ({device['mac']}, {vendor}) " + "is not in the list of devices this household has accounted " + "for. Identify it before assuming the worst — a new phone, a " + "guest, or a smart plug all look like this — but do not leave " + "it unexplained." + ), + severity=Severity.WARNING, + category=self.category, + data={ + "check": "unrecognised_device", + "ip": device["ips"][0], + "mac": device["mac"], + "vendor": device["vendor"], + "confidence": "medium", + }, + ) + ) + return findings + + def _count_findings(self, devices: list[dict]) -> list[Finding]: + expected = self._expected_device_count + if expected is None or len(devices) <= expected: + return [] + return [ + Finding( + title=( + f"More devices on the network than expected " + f"({len(devices)} seen, {expected} expected)" + ), + description=( + f"{len(devices)} devices are visible but only {expected} were " + "expected. Work through the inventory above and identify the " + "extras. Remember that phones, tablets, TVs, speakers, and smart " + "home gadgets each count as one device, so the real number is " + "usually higher than people guess." + ), + severity=Severity.WARNING, + category=self.category, + data={ + "check": "device_count_exceeded", + "device_count": len(devices), + "expected_device_count": expected, + "confidence": "medium", + }, + ) + ] + + +def _ip_sort_key(ip: str) -> tuple: + try: + return tuple(int(part) for part in ip.split(".")) + except ValueError: + return (999, 999, 999, 999) diff --git a/modules/network/router_security_audit/__init__.py b/modules/network/router_security_audit/__init__.py new file mode 100644 index 0000000..5453e49 --- /dev/null +++ b/modules/network/router_security_audit/__init__.py @@ -0,0 +1,360 @@ +"""Audit the home router's exposed surface, and give the reclaim procedure. + +When someone has got onto a home network, the router is both the most likely +way in and the thing that has to be fixed first — a spotless laptop rejoins a +compromised network and is compromised again. Yet the router is usually the +one device nobody has ever logged into, still running its factory admin +password and 2019 firmware. + +This module checks the router from the inside of the network: which +administrative services it is offering to every device on the Wi-Fi, and +whether UPnP is enabled (which lets any program on the network open a hole in +the firewall without being asked). It touches only the default gateway — the +user's own router — with short, bounded TCP connections, and never attempts to +authenticate to it. +""" + +import socket + +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase +from rescue.runtime import load_content_module + +_NEIGHBORS_KEY = "rescue_lan_common_neighbors" + +_CONNECT_TIMEOUT = 1.0 +_SSDP_TIMEOUT = 2.0 + +_SSDP_ADDRESS = ("239.255.255.250", 1900) +_SSDP_DISCOVER = ( + "M-SEARCH * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + 'MAN: "ssdp:discover"\r\n' + "MX: 1\r\n" + "ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n" + "\r\n" +) + +# Administrative services checked on the gateway. Each entry says what the +# service is and how much it matters that it is reachable from the Wi-Fi. +_ADMIN_PORTS = [ + { + "port": 23, + "service": "Telnet", + "severity": Severity.CRITICAL, + "why": ( + "Telnet sends the admin password across the network in plain text, and " + "is the single most common way home routers are taken over by botnets. " + "No modern router needs it." + ), + }, + { + "port": 21, + "service": "FTP", + "severity": Severity.WARNING, + "why": ( + "FTP is unencrypted. If the router shares a USB drive this way, anyone " + "on the Wi-Fi can read it and capture the password." + ), + }, + { + "port": 22, + "service": "SSH", + "severity": Severity.WARNING, + "why": ( + "SSH access to the router is encrypted but is still a login prompt " + "offered to every device on the network. Disable it unless it is " + "deliberately used." + ), + }, + { + "port": 7547, + "service": "TR-069 remote management (CWMP)", + "severity": Severity.WARNING, + "why": ( + "TR-069 lets the internet provider reconfigure the router remotely. " + "Vulnerabilities in it have been used to compromise millions of home " + "routers, and it should not be reachable from inside the network." + ), + }, + { + "port": 80, + "service": "Router admin page (unencrypted HTTP)", + "severity": Severity.INFO, + "why": ( + "The admin page is served without encryption, so the admin password is " + "sent in the clear across the Wi-Fi every time it is used. Prefer the " + "HTTPS admin page if the router offers one." + ), + }, + { + "port": 8080, + "service": "Alternate admin page (unencrypted HTTP)", + "severity": Severity.INFO, + "why": "A second unencrypted administration interface is reachable.", + }, + { + "port": 443, + "service": "Router admin page (HTTPS)", + "severity": Severity.INFO, + "why": "This is the expected, encrypted way to administer the router.", + }, + { + "port": 8443, + "service": "Alternate admin page (HTTPS)", + "severity": Severity.INFO, + "why": "A second encrypted administration interface is reachable.", + }, +] + + +def _neighbors_module(): + return load_content_module("modules/network/lan_common/neighbors.py", _NEIGHBORS_KEY) + + +class Module(ModuleBase): + name = "router_security_audit" + category = "network" + platforms = [Platform.DARWIN, Platform.WIN32, Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 70 + depends_on = [] + estimated_duration = "15s" + + def check(self, profile: SystemProfile) -> CheckResult: + neighbors = _neighbors_module() + if neighbors is None: + return CheckResult( + module_name=self.name, + error="Local-network helper data could not be loaded.", + ) + + gateways = neighbors.read_gateways(profile.platform.value) + gateway_ips = [gw.ip for gw in gateways if gw.ip] + if not gateway_ips: + return CheckResult( + module_name=self.name, + error=( + "No default gateway is configured, so there is no router to audit. " + "Check that this machine is connected to the network." + ), + ) + + gateway_ip = gateway_ips[0] + findings = self._check_admin_ports(gateway_ip) + findings.extend(self._check_upnp(gateway_ip)) + findings.append(self._reminder_finding(gateway_ip)) + return CheckResult(module_name=self.name, findings=findings) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + """Guidance only — the toolkit never logs in to or reconfigures a router.""" + actions: list[Action] = [] + if not findings.findings: + return FixResult(module_name=self.name, actions=actions) + + gateway_ip = next( + ( + f.data.get("gateway_ip") + for f in findings.findings + if f.data.get("gateway_ip") + ), + "your router", + ) + + exposed = [ + f for f in findings.findings if f.data.get("check") == "admin_service_open" + ] + for finding in exposed: + if finding.severity == Severity.INFO: + continue + service = finding.data.get("service", "the service") + port = finding.data.get("port") + actions.append( + Action( + title=f"Turn off {service} on the router", + description=( + f"{finding.description}\n\n" + f"Sign in to the router at http://{gateway_ip}/ and look under " + "Administration, Management, or Advanced settings for the " + f"option controlling {service} (port {port}). Turn it off, save, " + "and re-run this check to confirm it is closed." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + data={"port": port, "service": service}, + ) + ) + + if any(f.data.get("check") == "upnp_enabled" for f in findings.findings): + actions.append( + Action( + title="Consider turning off UPnP", + description=( + "UPnP lets any program on the network tell the router to open " + "a port to the internet, with no password and no prompt. " + "Malware uses it to make an infected machine reachable from " + "outside.\n\n" + "Turning it off is a real trade-off: games consoles, some " + "video-call software, and torrent clients rely on it and may " + "warn about a 'strict NAT' afterwards. If you turn it off, " + "first check the router's port-forwarding list and delete any " + "rule you did not create — those are the holes UPnP already " + "opened." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + + actions.append( + Action( + title="Take the router back: the full reclaim procedure", + description=( + "If someone has been on this network, assume they have also been " + "on the router. Work through this in order, ideally on a computer " + "plugged in by cable:\n" + f"1. Sign in at http://{gateway_ip}/. If the password is still the " + "one printed on the router, assume the router is already " + "compromised.\n" + "2. Update the firmware first — later steps are pointless if a " + "known vulnerability is still present.\n" + "3. Set a new admin password, different from the Wi-Fi password.\n" + "4. Set a new Wi-Fi passphrase, and set security to WPA3 (or " + "WPA2-AES if WPA3 is not offered). Never WEP, never WPA/TKIP, " + "never open.\n" + "5. Turn off WPS. It allows joining with an 8-digit PIN that can " + "be guessed offline, and it defeats a strong Wi-Fi password.\n" + "6. Turn off remote/internet administration and 'cloud' management.\n" + "7. Check the DNS servers on the router's WAN or Internet page. If " + "they are not your provider's or a resolver you chose (for example " + "1.1.1.1 or 9.9.9.9), someone redirected the whole household's " + "browsing; reset them.\n" + "8. Delete port-forwarding rules and any 'DMZ host' you did not " + "create.\n" + "9. Check the guest network — leaving it open with no password is " + "an unlocked back door onto the same hardware.\n" + "10. Reboot the router, then re-run this check.\n\n" + "If the settings will not stick, or unwanted settings reappear, " + "factory reset the router (hold the reset pin for 30 seconds) and " + "set it up again from scratch — do not restore a saved " + "configuration backup, which would restore the attacker's changes " + "too." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + return FixResult(module_name=self.name, actions=actions) + + # ---------------- checks ---------------- + + def _check_admin_ports(self, gateway_ip: str) -> list[Finding]: + findings = [] + for entry in _ADMIN_PORTS: + if not _tcp_open(gateway_ip, entry["port"]): + continue + findings.append( + Finding( + title=f"Router offers {entry['service']} to every device on the network", + description=( + f"The router at {gateway_ip} accepts connections on port " + f"{entry['port']} ({entry['service']}). {entry['why']}" + ), + severity=entry["severity"], + category=self.category, + data={ + "check": "admin_service_open", + "gateway_ip": gateway_ip, + "port": entry["port"], + "service": entry["service"], + "confidence": "high", + }, + ) + ) + return findings + + def _check_upnp(self, gateway_ip: str) -> list[Finding]: + response = _ssdp_discover() + if not response: + return [] + return [ + Finding( + title="UPnP is enabled on the router", + description=( + "The router answered a UPnP discovery request, which means any " + "program on this network can ask it to open a port to the " + "internet without a password and without telling anyone. That is " + "convenient for games consoles and a well-used route for malware " + "to make an infected machine reachable from outside.\n\n" + "Check the router's port-forwarding list for rules you did not " + "create." + ), + severity=Severity.WARNING, + category=self.category, + data={ + "check": "upnp_enabled", + "gateway_ip": gateway_ip, + "confidence": "high", + }, + ) + ] + + def _reminder_finding(self, gateway_ip: str) -> Finding: + return Finding( + title="Router settings that this check cannot see", + description=( + f"Your router is at {gateway_ip}. Several of the settings that decide " + "whether someone can get back onto this network can only be seen by " + "signing in to it: the admin password, the Wi-Fi passphrase and " + "security mode, WPS, remote administration, the DNS servers it hands " + "out, port-forwarding rules, and the firmware version.\n\n" + "The remediation steps for this check walk through each of them in " + "order." + ), + severity=Severity.INFO, + category=self.category, + data={ + "check": "router_manual_review", + "gateway_ip": gateway_ip, + "confidence": "high", + }, + ) + + +# ---------------- helpers ---------------- + + +def _tcp_open(host: str, port: int) -> bool: + """Return True if a TCP connection to host:port is accepted.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(_CONNECT_TIMEOUT) + return sock.connect_ex((host, port)) == 0 + except OSError: + return False + + +def _ssdp_discover() -> str: + """Send one SSDP discovery request and return the first response.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(_SSDP_TIMEOUT) + sock.sendto(_SSDP_DISCOVER.encode("ascii"), _SSDP_ADDRESS) + data, _ = sock.recvfrom(2048) + return data.decode("utf-8", errors="replace") + except OSError: + return "" diff --git a/modules/security/browser_cryptojacking_check/__init__.py b/modules/security/browser_cryptojacking_check/__init__.py new file mode 100644 index 0000000..9eb265c --- /dev/null +++ b/modules/security/browser_cryptojacking_check/__init__.py @@ -0,0 +1,549 @@ +"""Detect in-browser cryptojacking (drive-by mining). + +Not all mining runs as a native process. A browser extension — or a page the +browser is forced to open at startup — can mine continuously for as long as +the browser is open, which on most people's machines is all day. Process-level +miner detection never sees it, because the process doing the work is Chrome. + +This module reads browser extension manifests and startup settings looking for +mining libraries and mining service endpoints, and checks whether the hosts +file has been made to resolve a mining domain somewhere unexpected. +""" + +import json +from pathlib import Path + +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase +from rescue.runtime import content_directory, load_content_module + +_IOC_DIR = content_directory("modules/security/cryptojacking_iocs") +_IOC_LOADER_KEY = "rescue_cryptojacking_iocs_loader" + +# Bounds on read-only filesystem work. +_MAX_EXTENSIONS_PER_BROWSER = 300 +_MAX_FILES_PER_EXTENSION = 40 +_MAX_READ_BYTES = 512 * 1024 +# JSON config files are parsed whole rather than sampled, so they get their own +# (larger) ceiling — a Chrome Preferences file is often several megabytes. +_MAX_JSON_BYTES = 16 * 1024 * 1024 + +# Addresses that mean "block this domain" rather than "send me here". A hosts +# entry pointing a mining domain at one of these is a protection, not a threat. +_BLACKHOLE_ADDRESSES = {"0.0.0.0", "127.0.0.1", "::", "::1"} + +_CHROMIUM_PROFILE_ROOTS = { + Platform.DARWIN: [ + "~/Library/Application Support/Google/Chrome", + "~/Library/Application Support/Microsoft Edge", + "~/Library/Application Support/BraveSoftware/Brave-Browser", + "~/Library/Application Support/Chromium", + "~/Library/Application Support/Vivaldi", + ], + Platform.WIN32: [ + "~/AppData/Local/Google/Chrome/User Data", + "~/AppData/Local/Microsoft/Edge/User Data", + "~/AppData/Local/BraveSoftware/Brave-Browser/User Data", + "~/AppData/Local/Chromium/User Data", + ], + Platform.LINUX: [ + "~/.config/google-chrome", + "~/.config/chromium", + "~/.config/microsoft-edge", + "~/.config/BraveSoftware/Brave-Browser", + ], +} + +_FIREFOX_PROFILE_ROOTS = { + Platform.DARWIN: ["~/Library/Application Support/Firefox/Profiles"], + Platform.WIN32: ["~/AppData/Roaming/Mozilla/Firefox/Profiles"], + Platform.LINUX: ["~/.mozilla/firefox"], +} + +_HOSTS_FILES = { + Platform.DARWIN: "/etc/hosts", + Platform.LINUX: "/etc/hosts", + Platform.WIN32: r"C:\Windows\System32\drivers\etc\hosts", +} + +# Files inside an extension worth reading. Mining code lives in the background +# worker or a content script, never in the icons. +_SCANNABLE_SUFFIXES = {".js", ".json", ".html"} + + +def _load_iocs(): + """Load the shared cryptojacking IOC database, or None if unavailable.""" + loader = load_content_module( + "modules/security/cryptojacking_iocs/loader.py", _IOC_LOADER_KEY + ) + if loader is None: + return None + try: + return loader.load_cryptojacking_iocs(_IOC_DIR) + except Exception: + return None + + +_IOCS = _load_iocs() + + +class Module(ModuleBase): + name = "browser_cryptojacking_check" + category = "security" + platforms = [Platform.DARWIN, Platform.WIN32, Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 65 + depends_on = [] + estimated_duration = "20s" + + def check(self, profile: SystemProfile) -> CheckResult: + if _IOCS is None: + return CheckResult( + module_name=self.name, + error="Cryptojacking indicator data could not be loaded.", + ) + + findings: list[Finding] = [] + findings.extend(self._check_chromium_extensions(profile.platform)) + findings.extend(self._check_firefox_extensions(profile.platform)) + findings.extend(self._check_startup_pages(profile.platform)) + findings.extend(self._check_hosts_file(profile.platform)) + return CheckResult(module_name=self.name, findings=findings) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + """Guidance only — this module never edits browser or system files.""" + actions: list[Action] = [] + + for finding in findings.findings: + check = finding.data.get("check") + if check == "mining_extension": + name = finding.data.get("extension_name", "the extension") + browser = finding.data.get("browser", "your browser") + actions.append( + Action( + title=f"Remove the mining extension '{name}' from {browser}", + description=( + f"{finding.description}\n\n" + f"1. Open {browser}'s extensions page and remove '{name}'.\n" + "2. Check every browser profile — extensions are per-profile, " + "and a second profile you rarely use keeps mining.\n" + "3. Check whether the extension was installed by policy " + "(it will say 'Installed by your administrator' and cannot " + "simply be removed). If it was, something with admin rights " + "put it there and the machine needs a deeper clean.\n" + "4. Sign out of browser sync while cleaning up, or the " + "extension can be re-synced back onto the machine." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + data={"extension_name": name, "browser": browser}, + ) + ) + elif check == "mining_startup_page": + actions.append( + Action( + title="Reset the browser's startup and homepage settings", + description=( + f"{finding.description}\n\n" + "Open the browser's settings and reset 'On startup', " + "homepage, and search engine to what you actually want. " + "If the setting will not stick, an extension or a device " + "management policy is re-applying it." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + elif check == "mining_hosts_entry": + actions.append( + Action( + title="Remove the mining entry from the hosts file", + description=( + f"{finding.description}\n\n" + "Editing the hosts file requires administrator rights, so " + "whoever added this had them. Remove the line, then work " + "out how they got that access before considering this " + "closed." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + + if findings.findings: + actions.append( + Action( + title="Install a content blocker to stop drive-by mining", + description=( + "Browser mining is usually delivered by a compromised or " + "ad-supported page. A reputable content blocker (uBlock Origin " + "or the browser's own tracking protection set to strict) blocks " + "the mining endpoints outright, which is a durable fix rather " + "than a one-time cleanup." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + + return FixResult(module_name=self.name, actions=actions) + + # ---------------- Chromium-family ---------------- + + def _check_chromium_extensions(self, platform: Platform) -> list[Finding]: + findings: list[Finding] = [] + for root in _CHROMIUM_PROFILE_ROOTS.get(platform, []): + base = Path(root).expanduser() + browser = base.name + scanned = 0 + for version_dir in _extension_version_dirs(base): + if scanned >= _MAX_EXTENSIONS_PER_BROWSER: + break + scanned += 1 + finding = self._inspect_extension_dir(version_dir, browser) + if finding is not None: + findings.append(finding) + return findings + + def _inspect_extension_dir(self, version_dir: Path, browser: str) -> Finding | None: + manifest = _read_json(version_dir / "manifest.json") + name = _extension_name(manifest, version_dir) + + hit = self._match_known_extension(name) + if hit is None: + hit = self._scan_extension_files(version_dir) + if hit is None: + return None + + return Finding( + title=f"Browser extension appears to mine cryptocurrency: {name}", + description=( + f"The {browser} extension '{name}' {hit['detail']}. An extension with " + "mining code runs for as long as the browser is open, which is why the " + "machine stays hot and slow even when it looks idle." + ), + severity=hit["severity"], + category=self.category, + data={ + "check": "mining_extension", + "browser": browser, + "extension_name": name, + "extension_id": version_dir.parent.name, + "path": str(version_dir), + "indicator": hit["indicator"], + "evidence": hit["evidence"], + "confidence": hit["confidence"], + }, + ) + + def _scan_extension_files(self, version_dir: Path) -> dict | None: + """Read a bounded sample of an extension's code looking for miners.""" + scanned = 0 + for path in _bounded_walk(version_dir, _MAX_FILES_PER_EXTENSION): + if path.suffix.lower() not in _SCANNABLE_SUFFIXES: + continue + scanned += 1 + content = _read_text(path) + if not content: + continue + hit = self._match_content(content) + if hit is not None: + hit["detail"] += f" (found in {path.name})" + return hit + return None + + # ---------------- Firefox ---------------- + + def _check_firefox_extensions(self, platform: Platform) -> list[Finding]: + findings: list[Finding] = [] + for root in _FIREFOX_PROFILE_ROOTS.get(platform, []): + base = Path(root).expanduser() + if not _is_dir(base): + continue + for profile_dir in _bounded_dirs(base, 20): + data = _read_json(profile_dir / "extensions.json") + for addon in (data.get("addons") or [])[:_MAX_EXTENSIONS_PER_BROWSER]: + name = _firefox_addon_name(addon) + hit = self._match_known_extension(name) + if hit is None: + continue + findings.append( + Finding( + title=f"Browser extension appears to mine cryptocurrency: {name}", + description=( + f"The Firefox add-on '{name}' {hit['detail']}. Remove it " + "from about:addons." + ), + severity=hit["severity"], + category=self.category, + data={ + "check": "mining_extension", + "browser": "Firefox", + "extension_name": name, + "extension_id": addon.get("id", ""), + "path": str(profile_dir), + "indicator": hit["indicator"], + "evidence": hit["evidence"], + "confidence": hit["confidence"], + }, + ) + ) + return findings + + # ---------------- startup pages ---------------- + + def _check_startup_pages(self, platform: Platform) -> list[Finding]: + findings: list[Finding] = [] + for root in _CHROMIUM_PROFILE_ROOTS.get(platform, []): + base = Path(root).expanduser() + if not _is_dir(base): + continue + for profile_dir in _bounded_dirs(base, 20): + prefs = _read_json(profile_dir / "Preferences") + if not prefs: + continue + urls = list( + (prefs.get("session", {}) or {}).get("startup_urls", []) or [] + ) + homepage = prefs.get("homepage") + if isinstance(homepage, str): + urls.append(homepage) + for url in urls: + hit = self._match_content(str(url)) + if hit is None: + continue + findings.append( + Finding( + title="Browser is set to open a cryptomining page at startup", + description=( + f"{base.name} profile '{profile_dir.name}' opens {url} " + "automatically. That page hosts mining code, so the " + "browser starts mining as soon as it is launched." + ), + severity=Severity.CRITICAL, + category=self.category, + data={ + "check": "mining_startup_page", + "browser": base.name, + "profile": profile_dir.name, + "url": str(url), + "indicator": hit["indicator"], + "evidence": hit["evidence"], + "confidence": "high", + }, + ) + ) + return findings + + # ---------------- hosts file ---------------- + + def _check_hosts_file(self, platform: Platform) -> list[Finding]: + hosts_path = _HOSTS_FILES.get(platform) + if hosts_path is None: + return [] + content = _read_text(Path(hosts_path)) + if not content: + return [] + + findings: list[Finding] = [] + for line in content.splitlines(): + stripped = line.split("#", 1)[0].strip() + if not stripped: + continue + parts = stripped.split() + if len(parts) < 2: + continue + address, hostnames = parts[0], parts[1:] + if address in _BLACKHOLE_ADDRESSES: + # A blocklist entry — this is someone protecting the machine. + continue + for hostname in hostnames: + domain = self._match_mining_domain(hostname) + if domain is None: + continue + findings.append( + Finding( + title=f"Hosts file points {hostname} at {address}", + description=( + f"The hosts file redirects {hostname}, a cryptomining " + f"service, to {address} instead of blocking it. Someone " + "with administrator rights edited this file to keep mining " + "traffic flowing even if the public service is blocked." + ), + severity=Severity.CRITICAL, + category=self.category, + data={ + "check": "mining_hosts_entry", + "hostname": hostname, + "address": address, + "path": str(hosts_path), + "confidence": "high", + }, + ) + ) + return findings + + # ---------------- matching ---------------- + + def _match_known_extension(self, name: str) -> dict | None: + lowered = (name or "").lower() + if not lowered: + return None + for extension in _IOCS.browser_extensions: + if extension.name.lower() in lowered: + return { + "indicator": "known_mining_extension", + "severity": _severity(extension.severity), + "confidence": "medium", + "detail": f"matches a known mining extension — {extension.description}", + "evidence": extension.name, + } + return None + + def _match_content(self, content: str) -> dict | None: + lowered = content.lower() + for domain in _IOCS.browser_script_domains: + if domain.value.lower() in lowered: + return { + "indicator": "mining_service_endpoint", + "severity": _severity(domain.severity), + "confidence": "high", + "detail": f"contains a reference to {domain.value} — {domain.description}", + "evidence": domain.value, + } + for marker in _IOCS.browser_script_markers: + if marker.value.lower() in lowered: + return { + "indicator": "mining_library_call", + "severity": _severity(marker.severity), + "confidence": "medium", + "detail": f"contains the mining library call {marker.value} — {marker.description}", + "evidence": marker.value, + } + return None + + def _match_mining_domain(self, hostname: str) -> str | None: + lowered = hostname.lower() + for domain in _IOCS.browser_script_domains: + if domain.value.lower() in lowered: + return domain.value + for pool in _IOCS.pools: + if pool.domain.lower() in lowered: + return pool.domain + return None + + +# ---------------- helpers ---------------- + + +def _severity(value: str) -> Severity: + return Severity.CRITICAL if value == "critical" else Severity.WARNING + + +def _is_dir(path: Path) -> bool: + try: + return path.is_dir() + except OSError: + return False + + +def _bounded_dirs(base: Path, limit: int) -> list[Path]: + try: + if not base.is_dir(): + return [] + return [p for p in sorted(base.iterdir()) if _is_dir(p)][:limit] + except OSError: + return [] + + +def _extension_version_dirs(base: Path) -> list[Path]: + """Return /Extensions// directories under a browser root.""" + version_dirs: list[Path] = [] + for profile_dir in _bounded_dirs(base, 20): + extensions_dir = profile_dir / "Extensions" + for extension_dir in _bounded_dirs(extensions_dir, _MAX_EXTENSIONS_PER_BROWSER): + version_dirs.extend(_bounded_dirs(extension_dir, 5)) + return version_dirs + + +def _bounded_walk(base: Path, limit: int) -> list[Path]: + """Return up to ``limit`` files from ``base``, one level of subdirectories.""" + files: list[Path] = [] + try: + if not base.is_dir(): + return files + for path in sorted(base.iterdir()): + if len(files) >= limit: + return files + if path.is_file(): + files.append(path) + elif path.is_dir(): + for child in sorted(path.iterdir()): + if len(files) >= limit: + return files + if child.is_file(): + files.append(child) + except OSError: + return files + return files + + +def _read_text(path: Path) -> str: + try: + if not path.is_file(): + return "" + if path.stat().st_size > _MAX_READ_BYTES * 8: + return "" + with open(path, "r", errors="replace") as f: + return f.read(_MAX_READ_BYTES) + except (OSError, ValueError): + return "" + + +def _read_json(path: Path) -> dict: + """Read a JSON config file whole, within a size limit. + + Unlike the code scan, this cannot use the truncating reader: a browser's + Preferences file routinely exceeds the code-scan read limit, and half a + JSON document parses as nothing at all. + """ + try: + if not path.is_file() or path.stat().st_size > _MAX_JSON_BYTES: + return {} + with open(path, "r", errors="replace") as f: + data = json.load(f) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def _extension_name(manifest: dict, version_dir: Path) -> str: + name = manifest.get("name", "") + # Chrome stores localised names as "__MSG_appName__"; fall back to the + # extension ID, which is still enough for the user to find it. + if not isinstance(name, str) or not name or name.startswith("__MSG_"): + return version_dir.parent.name + return name + + +def _firefox_addon_name(addon: dict) -> str: + default_locale = addon.get("defaultLocale") or {} + name = default_locale.get("name") if isinstance(default_locale, dict) else None + if isinstance(name, str) and name: + return name + return str(addon.get("id", "unknown add-on")) diff --git a/modules/security/crypto_miner_persistence/__init__.py b/modules/security/crypto_miner_persistence/__init__.py new file mode 100644 index 0000000..513b232 --- /dev/null +++ b/modules/security/crypto_miner_persistence/__init__.py @@ -0,0 +1,532 @@ +"""Detect cryptojacking that has been made to survive a reboot. + +``crypto_miner_detect`` looks at what is running right now. That is only half +the problem: killing a miner process achieves nothing if a LaunchAgent, cron +entry, systemd unit, Run key, or scheduled task starts it again a minute +later. This module looks for the persistence mechanism and the miner's own +configuration (pool address, wallet address), which together are what a +victim actually has to remove. + +All checks are read-only and bounded — a fixed list of persistence locations, +one level of globbing, and a cap on the number of files read — so this is safe +to run on a machine that is already struggling under a miner's CPU load. +""" + +import re +import subprocess +from pathlib import Path + +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase +from rescue.runtime import content_directory, load_content_module + +_IOC_DIR = content_directory("modules/security/cryptojacking_iocs") +_IOC_LOADER_KEY = "rescue_cryptojacking_iocs_loader" + +# Command timeout for every external command this module runs. A compromised +# machine is often heavily loaded, so this is generous but always bounded. +_COMMAND_TIMEOUT = 15 + +# Caps on read-only filesystem work, so a check can never turn into an +# unbounded traversal of the user's home directory. +_MAX_FILES_PER_LOCATION = 200 +_MAX_READ_BYTES = 64 * 1024 + +# Monero addresses are 95 base58 characters starting with 4 or 8; a wallet +# address sitting in a startup command or config file is about as close to +# proof of mining as a local check can get. +_MONERO_ADDRESS = re.compile(r"\b[48][1-9A-HJ-NP-Za-km-z]{94}\b") + +# Miner command-line flags. XMRig and its forks share this vocabulary, so +# these catch renamed binaries that a name-only check would miss. +_MINER_ARGUMENTS = [ + re.compile(r"stratum\+(tcp|ssl)://", re.IGNORECASE), + re.compile(r"--donate-level", re.IGNORECASE), + re.compile(r"--cpu-max-threads-hint", re.IGNORECASE), + re.compile(r"--randomx", re.IGNORECASE), + re.compile(r"\s-o\s+\S+:(3333|4444|5555|7777|8888|9999|14433|14444|45700)\b"), +] + +_DARWIN_PERSISTENCE_DIRS = [ + "~/Library/LaunchAgents", + "/Library/LaunchAgents", + "/Library/LaunchDaemons", +] + +_LINUX_PERSISTENCE_DIRS = [ + "~/.config/systemd/user", + "/etc/systemd/system", + "/etc/systemd/user", + "/etc/cron.d", + "/etc/cron.hourly", + "/etc/cron.daily", +] + +_LINUX_PERSISTENCE_FILES = [ + "/etc/crontab", + "/etc/rc.local", +] + +_SHELL_PROFILES = [ + "~/.bashrc", + "~/.bash_profile", + "~/.profile", + "~/.zshrc", + "~/.zprofile", +] + +# Directories a dropped miner and its config realistically live in. Deliberately +# short: each is globbed one level deep only. +_DARWIN_DROP_DIRS = [ + "~/Library/Application Support", + "~/.config", + "/tmp", + "/var/tmp", + "/usr/local/bin", +] + +_LINUX_DROP_DIRS = [ + "~/.config", + "/tmp", + "/var/tmp", + "/dev/shm", + "/usr/local/bin", + "/opt", +] + +_WIN_RUN_KEYS = [ + (r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", "HKCU Run"), + (r"HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnce", "HKCU RunOnce"), + (r"HKLM\Software\Microsoft\Windows\CurrentVersion\Run", "HKLM Run"), + (r"HKLM\Software\Microsoft\Windows\CurrentVersion\RunOnce", "HKLM RunOnce"), +] + + +def _load_iocs(): + """Load the shared cryptojacking IOC database, or None if unavailable.""" + loader = load_content_module( + "modules/security/cryptojacking_iocs/loader.py", _IOC_LOADER_KEY + ) + if loader is None: + return None + try: + return loader.load_cryptojacking_iocs(_IOC_DIR) + except Exception: + return None + + +_IOCS = _load_iocs() + + +class Module(ModuleBase): + name = "crypto_miner_persistence" + category = "security" + platforms = [Platform.DARWIN, Platform.WIN32, Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 78 + depends_on = [] + estimated_duration = "15s" + + def check(self, profile: SystemProfile) -> CheckResult: + findings: list[Finding] = [] + + if profile.platform == Platform.DARWIN: + findings.extend(self._check_plists()) + findings.extend(self._check_cron()) + findings.extend(self._check_shell_profiles()) + findings.extend(self._check_dropped_configs(_DARWIN_DROP_DIRS)) + elif profile.platform == Platform.LINUX: + findings.extend(self._check_unit_and_cron_files()) + findings.extend(self._check_cron()) + findings.extend(self._check_shell_profiles()) + findings.extend(self._check_dropped_configs(_LINUX_DROP_DIRS)) + elif profile.platform == Platform.WIN32: + findings.extend(self._check_windows_run_keys()) + findings.extend(self._check_windows_scheduled_tasks()) + + return CheckResult(module_name=self.name, findings=findings) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + """Guidance only — this module never removes persistence itself. + + Deleting the startup entry without also removing the payload (or vice + versa) leaves a half-cleaned machine, and on a device the owner may + want examined later, deletion destroys the evidence. Every action is + therefore a manual step. + """ + actions: list[Action] = [] + if not findings.findings: + return FixResult(module_name=self.name, actions=actions) + + for finding in findings.findings: + location = finding.data.get("location", "unknown location") + actions.append( + Action( + title=f"Remove the mining startup entry at {location}", + description=( + f"{finding.title}\n\n" + "Remove it in this order, or the miner comes straight back:\n" + "1. Write down (or screenshot) the full entry first — the pool " + "and wallet address are the evidence of who was mining.\n" + "2. Disable the startup entry, then reboot.\n" + "3. Delete the miner binary and its config file.\n" + "4. Re-run this check to confirm nothing recreated it.\n\n" + "If the entry reappears after a reboot, something else on the " + "machine is still running with enough privilege to recreate it. " + "Stop cleaning up and treat the machine as fully compromised." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + data={"location": location}, + ) + ) + + actions.append( + Action( + title="Find out how the miner got installed", + description=( + "Cryptojacking is almost never the first thing that happens to a " + "machine — it is what an attacker does with access they already " + "have. Before declaring this fixed, check how they got in: a " + "pirated or cracked installer, a malicious browser extension, an " + "exposed remote-access service, or a shared/compromised password. " + "Run the stalkerware, remote-access, and router checks in this " + "toolkit as well." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + return FixResult(module_name=self.name, actions=actions) + + # ---------------- macOS / Linux ---------------- + + def _check_plists(self) -> list[Finding]: + findings: list[Finding] = [] + for directory in _DARWIN_PERSISTENCE_DIRS: + for path in _bounded_files(directory, "*.plist"): + content = _read_text(path) + if not content: + continue + hit = self._classify(content) + if hit is None: + continue + findings.append( + self._make_finding( + check="miner_launch_item", + location=str(path), + mechanism="launchd job", + hit=hit, + ) + ) + return findings + + def _check_unit_and_cron_files(self) -> list[Finding]: + findings: list[Finding] = [] + for directory in _LINUX_PERSISTENCE_DIRS: + for path in _bounded_files(directory, "*"): + content = _read_text(path) + if not content: + continue + hit = self._classify(content) + if hit is None: + continue + mechanism = "systemd unit" if path.suffix in {".service", ".timer"} else "cron entry" + findings.append( + self._make_finding( + check="miner_launch_item", + location=str(path), + mechanism=mechanism, + hit=hit, + ) + ) + for file_path in _LINUX_PERSISTENCE_FILES: + path = Path(file_path) + content = _read_text(path) + if not content: + continue + hit = self._classify(content) + if hit is not None: + findings.append( + self._make_finding( + check="miner_launch_item", + location=str(path), + mechanism="system startup file", + hit=hit, + ) + ) + return findings + + def _check_cron(self) -> list[Finding]: + output = _run(["crontab", "-l"]) + if not output: + return [] + findings: list[Finding] = [] + for line in output.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + hit = self._classify(stripped) + if hit is not None: + findings.append( + self._make_finding( + check="miner_cron_job", + location="user crontab", + mechanism="cron job", + hit=hit, + extra={"entry": stripped[:400]}, + ) + ) + return findings + + def _check_shell_profiles(self) -> list[Finding]: + findings: list[Finding] = [] + for profile_path in _SHELL_PROFILES: + path = Path(profile_path).expanduser() + content = _read_text(path) + if not content: + continue + hit = self._classify(content) + if hit is not None: + findings.append( + self._make_finding( + check="miner_shell_profile", + location=str(path), + mechanism="shell startup file", + hit=hit, + ) + ) + return findings + + def _check_dropped_configs(self, directories: list[str]) -> list[Finding]: + """Look for a miner config sitting in a directory malware drops into.""" + findings: list[Finding] = [] + for directory in directories: + for path in _bounded_files(directory, "*.json"): + content = _read_text(path) + if not content: + continue + hit = self._classify(content, require_strong=True) + if hit is None: + continue + findings.append( + self._make_finding( + check="miner_config_file", + location=str(path), + mechanism="miner configuration file", + hit=hit, + ) + ) + return findings + + # ---------------- Windows ---------------- + + def _check_windows_run_keys(self) -> list[Finding]: + findings: list[Finding] = [] + for key, label in _WIN_RUN_KEYS: + output = _run(["reg", "query", key]) + if not output: + continue + for line in output.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("HKEY_"): + continue + hit = self._classify(stripped) + if hit is not None: + findings.append( + self._make_finding( + check="miner_run_key", + location=f"{label}: {stripped.split()[0]}", + mechanism="registry Run key", + hit=hit, + extra={"registry_key": key, "entry": stripped[:400]}, + ) + ) + return findings + + def _check_windows_scheduled_tasks(self) -> list[Finding]: + output = _run(["schtasks", "/query", "/fo", "csv", "/v"]) + if not output: + return [] + findings: list[Finding] = [] + seen: set[str] = set() + for line in output.splitlines()[1:]: + hit = self._classify(line) + if hit is None: + continue + task_name = line.split(",")[1].strip('"') if "," in line else "unknown task" + if task_name in seen: + continue + seen.add(task_name) + findings.append( + self._make_finding( + check="miner_scheduled_task", + location=task_name, + mechanism="scheduled task", + hit=hit, + ) + ) + return findings + + # ---------------- classification ---------------- + + def _classify(self, text: str, require_strong: bool = False) -> dict | None: + """Return the strongest cryptojacking indicator in ``text``, if any. + + ``require_strong`` drops name-only matches, which is what the dropped + config scan needs: an arbitrary JSON file mentioning "nicehash" is not + interesting, but one containing a wallet address or stratum URL is. + """ + lowered = text.lower() + + wallet = _MONERO_ADDRESS.search(text) + if wallet is not None: + return { + "indicator": "monero_wallet_address", + "severity": Severity.CRITICAL, + "confidence": "high", + "detail": ( + "contains a Monero wallet address " + f"({wallet.group(0)[:12]}…{wallet.group(0)[-6:]}), which is where " + "the mined currency is being paid" + ), + "evidence": wallet.group(0), + } + + for pattern in _MINER_ARGUMENTS: + match = pattern.search(text) + if match is not None: + return { + "indicator": "miner_arguments", + "severity": Severity.CRITICAL, + "confidence": "high", + "detail": ( + f"invokes a program with mining arguments ({match.group(0).strip()}), " + "which is how a miner is pointed at a pool" + ), + "evidence": match.group(0).strip(), + } + + if _IOCS is not None: + for pool in _IOCS.pools: + if pool.domain.lower() in lowered: + return { + "indicator": "mining_pool", + "severity": Severity.CRITICAL, + "confidence": "high", + "detail": f"references the mining pool {pool.domain} — {pool.description}", + "evidence": pool.domain, + } + + if require_strong: + return None + + if _IOCS is not None: + for miner in _IOCS.miners: + if miner.pattern.lower() in lowered: + return { + "indicator": "known_miner", + "severity": ( + Severity.CRITICAL + if miner.severity == "critical" + else Severity.WARNING + ), + "confidence": "medium", + "detail": f"starts {miner.name} — {miner.description}", + "evidence": miner.pattern, + } + return None + + def _make_finding( + self, + check: str, + location: str, + mechanism: str, + hit: dict, + extra: dict | None = None, + ) -> Finding: + data = { + "check": check, + "location": location, + "mechanism": mechanism, + "indicator": hit["indicator"], + "confidence": hit["confidence"], + "evidence": hit["evidence"], + } + if extra: + data.update(extra) + return Finding( + title=f"Mining startup entry found in {location}", + description=( + f"This {mechanism} {hit['detail']}. It runs automatically, so the " + "miner restarts after every reboot even if you kill the process. " + "Symptoms are a machine that runs hot, loud, and slow while doing " + "nothing, and a higher electricity bill." + ), + severity=hit["severity"], + category=self.category, + data=data, + ) + + +# ---------------- bounded helpers ---------------- + + +def _run(command: list[str]) -> str: + """Run a read-only command with a timeout; return "" on any failure.""" + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=_COMMAND_TIMEOUT, + ) + except (OSError, subprocess.SubprocessError): + return "" + return result.stdout or "" + + +def _bounded_files(directory: str, pattern: str) -> list[Path]: + """Glob one level of ``directory``, capped, skipping anything unreadable.""" + try: + base = Path(directory).expanduser() + if not base.is_dir(): + return [] + files: list[Path] = [] + for path in sorted(base.glob(pattern)): + if len(files) >= _MAX_FILES_PER_LOCATION: + break + try: + if path.is_file(): + files.append(path) + except OSError: + continue + return files + except OSError: + return [] + + +def _read_text(path: Path) -> str: + """Read at most ``_MAX_READ_BYTES`` of a file; return "" if unreadable.""" + try: + if not path.is_file(): + return "" + if path.stat().st_size > _MAX_READ_BYTES * 16: + return "" + with open(path, "r", errors="replace") as f: + return f.read(_MAX_READ_BYTES) + except (OSError, ValueError): + return "" diff --git a/modules/security/cryptojacking_iocs/__init__.py b/modules/security/cryptojacking_iocs/__init__.py new file mode 100644 index 0000000..9a16a8b --- /dev/null +++ b/modules/security/cryptojacking_iocs/__init__.py @@ -0,0 +1,6 @@ +"""Data-only IOC package for cryptojacking detection. + +Contains no ``Module`` class, so ``rescue.registry.discover_modules`` skips it +during discovery. The crypto_miner_persistence, win_crypto_miner_detect, and +browser_cryptojacking_check modules load this data through ``loader.py``. +""" diff --git a/modules/security/cryptojacking_iocs/browser_miners.json b/modules/security/cryptojacking_iocs/browser_miners.json new file mode 100644 index 0000000..7132cb1 --- /dev/null +++ b/modules/security/cryptojacking_iocs/browser_miners.json @@ -0,0 +1,50 @@ +{ + "version": "1.0.0", + "script_domains": [ + {"domain": "coinhive.com", "severity": "critical", "description": "Coinhive, the in-browser Monero miner that started the drive-by mining wave. The service shut down in 2019, so any surviving reference means stale injected code is still on the machine."}, + {"domain": "authedmine.com", "severity": "critical", "description": "Coinhive's 'opt-in' branded endpoint, used to evade blocklists."}, + {"domain": "coin-hive.com", "severity": "critical", "description": "Coinhive endpoint variant."}, + {"domain": "crypto-loot.com", "severity": "critical", "description": "Crypto-Loot browser mining service."}, + {"domain": "cryptoloot.pro", "severity": "critical", "description": "Crypto-Loot browser mining service endpoint."}, + {"domain": "coinimp.com", "severity": "critical", "description": "CoinIMP browser miner, a common Coinhive successor."}, + {"domain": "webminepool.com", "severity": "critical", "description": "WebMinePool in-browser mining service."}, + {"domain": "jsecoin.com", "severity": "critical", "description": "JSEcoin in-browser mining service."}, + {"domain": "minero.cc", "severity": "critical", "description": "Minero.cc in-browser mining service."}, + {"domain": "coinwebmining.com", "severity": "critical", "description": "In-browser mining service endpoint."}, + {"domain": "webmine.cz", "severity": "critical", "description": "Webmine.cz in-browser mining service."}, + {"domain": "monerise.com", "severity": "critical", "description": "Monerise browser mining service."}, + {"domain": "cryptonight.wasm", "severity": "critical", "description": "CryptoNight WebAssembly mining module, the hashing core of most browser miners."}, + {"domain": "deepminer", "severity": "critical", "description": "deepMiner, a self-hosted open-source browser mining kit used after Coinhive shut down."} + ], + "script_markers": [ + {"marker": "CoinHive.Anonymous", "severity": "critical", "description": "Coinhive JavaScript miner initialisation."}, + {"marker": "CoinHive.User", "severity": "critical", "description": "Coinhive JavaScript miner initialisation."}, + {"marker": "CryptoLoot.Anonymous", "severity": "critical", "description": "Crypto-Loot JavaScript miner initialisation."}, + {"marker": "Client.Anonymous", "severity": "warning", "description": "CoinIMP/deepMiner client initialisation used by several browser mining kits."}, + {"marker": "cryptonight", "severity": "warning", "description": "CryptoNight hashing algorithm reference inside browser code."}, + {"marker": "stratum+tcp", "severity": "critical", "description": "Stratum mining protocol URL embedded in browser or extension code."}, + {"marker": "startMining", "severity": "warning", "description": "Mining start call used by several browser mining libraries."} + ], + "extensions": [ + { + "name": "SafeBrowse", + "severity": "critical", + "description": "Chrome extension that was caught running a hidden Monero miner in the background of every browsing session." + }, + { + "name": "Archive Poster", + "severity": "critical", + "description": "Tumblr helper extension that was compromised and shipped a hidden Coinhive miner to its users." + }, + { + "name": "Ldoco", + "severity": "warning", + "description": "Extension family repeatedly re-uploaded to the Chrome Web Store carrying browser mining payloads." + }, + { + "name": "Bitcoin Mining", + "severity": "warning", + "description": "Extension advertising in-browser mining. Only expected if the device owner deliberately installed it." + } + ] +} diff --git a/modules/security/cryptojacking_iocs/known_miners.json b/modules/security/cryptojacking_iocs/known_miners.json new file mode 100644 index 0000000..1c36c46 --- /dev/null +++ b/modules/security/cryptojacking_iocs/known_miners.json @@ -0,0 +1,181 @@ +{ + "version": "1.0.0", + "entries": [ + { + "pattern": "xmrig", + "name": "XMRig", + "family": "xmrig", + "severity": "critical", + "platforms": ["darwin", "linux", "win32"], + "description": "XMRig is the most widely deployed Monero miner in cryptojacking campaigns. It is legitimate open-source software, but on a machine nobody deliberately set up for mining its presence means someone else is spending your electricity and CPU." + }, + { + "pattern": "xmr-stak", + "name": "XMR-Stak", + "family": "xmr_stak", + "severity": "critical", + "platforms": ["darwin", "linux", "win32"], + "description": "XMR-Stak is a Monero/Cryptonight miner frequently dropped by cryptojacking loaders." + }, + { + "pattern": "minerd", + "name": "cpuminer (minerd)", + "family": "cpuminer", + "severity": "critical", + "platforms": ["darwin", "linux", "win32"], + "description": "The cpuminer daemon, historically dropped by worms that brute-force SSH and Redis." + }, + { + "pattern": "cpuminer", + "name": "cpuminer", + "family": "cpuminer", + "severity": "critical", + "platforms": ["darwin", "linux", "win32"], + "description": "Generic CPU mining binary used across many cryptojacking families." + }, + { + "pattern": "cgminer", + "name": "CGMiner", + "family": "cgminer", + "severity": "warning", + "platforms": ["darwin", "linux", "win32"], + "description": "ASIC/GPU mining software. Legitimate for deliberate mining rigs; unexpected on a personal machine." + }, + { + "pattern": "bfgminer", + "name": "BFGMiner", + "family": "bfgminer", + "severity": "warning", + "platforms": ["darwin", "linux", "win32"], + "description": "ASIC/FPGA mining software. Unexpected on a personal machine." + }, + { + "pattern": "ethminer", + "name": "ethminer", + "family": "ethminer", + "severity": "warning", + "platforms": ["darwin", "linux", "win32"], + "description": "Ethash GPU miner, commonly repurposed for unauthorised GPU mining." + }, + { + "pattern": "phoenixminer", + "name": "PhoenixMiner", + "family": "phoenixminer", + "severity": "warning", + "platforms": ["linux", "win32"], + "description": "Closed-source GPU miner distributed with cracked software and game mods." + }, + { + "pattern": "nbminer", + "name": "NBMiner", + "family": "nbminer", + "severity": "warning", + "platforms": ["linux", "win32"], + "description": "GPU miner bundled into pirated installers and 'optimiser' downloads." + }, + { + "pattern": "lolminer", + "name": "lolMiner", + "family": "lolminer", + "severity": "warning", + "platforms": ["linux", "win32"], + "description": "GPU miner used both legitimately and in silent-mining bundles." + }, + { + "pattern": "teamredminer", + "name": "TeamRedMiner", + "family": "teamredminer", + "severity": "warning", + "platforms": ["linux", "win32"], + "description": "AMD GPU miner seen in unauthorised mining deployments." + }, + { + "pattern": "srbminer", + "name": "SRBMiner", + "family": "srbminer", + "severity": "warning", + "platforms": ["linux", "win32"], + "description": "Multi-algorithm miner used in cryptojacking bundles." + }, + { + "pattern": "nanominer", + "name": "nanominer", + "family": "nanominer", + "severity": "warning", + "platforms": ["linux", "win32"], + "description": "Multi-algorithm miner distributed by nanopool, frequently repackaged by cryptojackers." + }, + { + "pattern": "nicehash", + "name": "NiceHash miner", + "family": "nicehash", + "severity": "warning", + "platforms": ["darwin", "linux", "win32"], + "description": "NiceHash mining client. Legitimate if the owner installed it deliberately; otherwise it is mining for someone else's account." + }, + { + "pattern": "kdevtmpfsi", + "name": "kdevtmpfsi", + "family": "kinsing", + "severity": "critical", + "platforms": ["linux"], + "description": "The Kinsing malware's miner payload. The name deliberately imitates a kernel thread; a real kernel thread never lives in /tmp or /var/tmp." + }, + { + "pattern": "kinsing", + "name": "Kinsing loader", + "family": "kinsing", + "severity": "critical", + "platforms": ["linux"], + "description": "Kinsing is a Go-based cryptojacking loader that installs the kdevtmpfsi miner and spreads to other hosts." + }, + { + "pattern": "sysrv", + "name": "Sysrv-hello botnet", + "family": "sysrv", + "severity": "critical", + "platforms": ["linux", "win32"], + "description": "Sysrv-hello is a cryptojacking worm that mines Monero and spreads across the local network." + }, + { + "pattern": "dbused", + "name": "dbused (miner masquerade)", + "family": "generic_masquerade", + "severity": "critical", + "platforms": ["linux"], + "description": "Miner payload masquerading as the D-Bus daemon (the real service is 'dbus-daemon')." + }, + { + "pattern": "mshelper", + "name": "mshelper", + "family": "osx_mshelper", + "severity": "critical", + "platforms": ["darwin"], + "description": "macOS adware/miner payload that pins a CPU core at 100% and is installed by fake Flash Player updates." + }, + { + "pattern": "osaminer", + "name": "OSAMiner", + "family": "osaminer", + "severity": "critical", + "platforms": ["darwin"], + "description": "macOS Monero miner distributed inside pirated games and software, hidden in run-only AppleScripts." + }, + { + "pattern": "moneroocean", + "name": "MoneroOcean installer", + "family": "moneroocean", + "severity": "critical", + "platforms": ["darwin", "linux"], + "description": "The MoneroOcean setup script installs XMRig as a service under an attacker's wallet." + }, + { + "pattern": "coinminer", + "name": "Generic coin miner", + "family": "generic", + "severity": "critical", + "platforms": ["darwin", "linux", "win32"], + "description": "Binary or service explicitly named as a coin miner." + } + ] +} diff --git a/modules/security/cryptojacking_iocs/known_pools.json b/modules/security/cryptojacking_iocs/known_pools.json new file mode 100644 index 0000000..656945f --- /dev/null +++ b/modules/security/cryptojacking_iocs/known_pools.json @@ -0,0 +1,22 @@ +{ + "version": "1.0.0", + "ports": [3333, 4444, 5555, 7777, 8888, 9999, 14433, 14444, 45700], + "entries": [ + {"domain": "pool.minexmr.com", "coin": "monero", "severity": "critical", "description": "MinerXMR Monero pool, the most common XMRig default target."}, + {"domain": "minexmr.com", "coin": "monero", "severity": "critical", "description": "MinerXMR Monero pool."}, + {"domain": "supportxmr.com", "coin": "monero", "severity": "critical", "description": "SupportXMR Monero pool, heavily used by cryptojacking payloads."}, + {"domain": "moneroocean.stream", "coin": "monero", "severity": "critical", "description": "MoneroOcean pool used by the widely copied MoneroOcean auto-installer script."}, + {"domain": "monerohash.com", "coin": "monero", "severity": "critical", "description": "MoneroHash pool."}, + {"domain": "nanopool.org", "coin": "multi", "severity": "critical", "description": "Nanopool multi-coin pool."}, + {"domain": "dwarfpool.com", "coin": "multi", "severity": "critical", "description": "Dwarfpool multi-coin pool."}, + {"domain": "minergate.com", "coin": "multi", "severity": "critical", "description": "MinerGate pool and bundled miner client."}, + {"domain": "2miners.com", "coin": "multi", "severity": "warning", "description": "2Miners pool."}, + {"domain": "f2pool.com", "coin": "multi", "severity": "warning", "description": "F2Pool mining pool."}, + {"domain": "ethermine.org", "coin": "ethereum", "severity": "warning", "description": "Ethermine pool."}, + {"domain": "hiveon.net", "coin": "multi", "severity": "warning", "description": "Hiveon pool."}, + {"domain": "herominers.com", "coin": "multi", "severity": "warning", "description": "HeroMiners pool."}, + {"domain": "c3pool.com", "coin": "monero", "severity": "critical", "description": "C3Pool Monero pool, commonly hard-coded by cryptojacking installers."}, + {"domain": "nicehash.com", "coin": "multi", "severity": "warning", "description": "NiceHash hashpower marketplace."}, + {"domain": "unmineable.com", "coin": "multi", "severity": "warning", "description": "Unmineable pool, used by bundled 'free game' miners."} + ] +} diff --git a/modules/security/cryptojacking_iocs/loader.py b/modules/security/cryptojacking_iocs/loader.py new file mode 100644 index 0000000..2629160 --- /dev/null +++ b/modules/security/cryptojacking_iocs/loader.py @@ -0,0 +1,147 @@ +"""Loader for the shared cryptojacking IOC data files. + +Mirrors ``modules/security/ai_worm_iocs/loader.py``: parsed data is cached +per data directory so repeated module runs re-use one parse, and a missing +file degrades to an empty list rather than raising. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass(frozen=True) +class MinerIOC: + pattern: str + name: str + family: str + severity: str + platforms: tuple[str, ...] + description: str + + +@dataclass(frozen=True) +class PoolIOC: + domain: str + coin: str + severity: str + description: str + + +@dataclass(frozen=True) +class BrowserScriptIOC: + value: str + severity: str + description: str + + +@dataclass(frozen=True) +class BrowserExtensionIOC: + name: str + severity: str + description: str + + +@dataclass +class CryptojackingIOCs: + version: str + miners: list[MinerIOC] = field(default_factory=list) + pools: list[PoolIOC] = field(default_factory=list) + pool_ports: tuple[int, ...] = () + browser_script_domains: list[BrowserScriptIOC] = field(default_factory=list) + browser_script_markers: list[BrowserScriptIOC] = field(default_factory=list) + browser_extensions: list[BrowserExtensionIOC] = field(default_factory=list) + + def miners_for(self, platform: str) -> list[MinerIOC]: + return [m for m in self.miners if platform in m.platforms] + + +_cache: CryptojackingIOCs | None = None +_cache_dir: Path | None = None + + +def _clear_cache() -> None: + global _cache, _cache_dir + _cache = None + _cache_dir = None + + +def load_cryptojacking_iocs(data_dir: Path | None = None) -> CryptojackingIOCs: + global _cache, _cache_dir + if data_dir is None: + data_dir = Path(__file__).parent + if _cache is not None and _cache_dir == data_dir: + return _cache + + manifest = _load_json(data_dir / "manifest.json") + db = CryptojackingIOCs(version=manifest.get("version", "unknown")) + + for entry in _load_entries(data_dir / "known_miners.json"): + db.miners.append( + MinerIOC( + pattern=entry["pattern"], + name=entry.get("name", entry["pattern"]), + family=entry.get("family", "generic"), + severity=entry.get("severity", "warning"), + platforms=tuple(entry.get("platforms", ["darwin", "linux", "win32"])), + description=entry.get("description", ""), + ) + ) + + pools_data = _load_json(data_dir / "known_pools.json") + db.pool_ports = tuple(pools_data.get("ports", [])) + for entry in pools_data.get("entries", []): + db.pools.append( + PoolIOC( + domain=entry["domain"], + coin=entry.get("coin", "unknown"), + severity=entry.get("severity", "warning"), + description=entry.get("description", ""), + ) + ) + + browser_data = _load_json(data_dir / "browser_miners.json") + for entry in browser_data.get("script_domains", []): + db.browser_script_domains.append( + BrowserScriptIOC( + value=entry["domain"], + severity=entry.get("severity", "warning"), + description=entry.get("description", ""), + ) + ) + for entry in browser_data.get("script_markers", []): + db.browser_script_markers.append( + BrowserScriptIOC( + value=entry["marker"], + severity=entry.get("severity", "warning"), + description=entry.get("description", ""), + ) + ) + for entry in browser_data.get("extensions", []): + db.browser_extensions.append( + BrowserExtensionIOC( + name=entry["name"], + severity=entry.get("severity", "warning"), + description=entry.get("description", ""), + ) + ) + + _cache = db + _cache_dir = data_dir + return db + + +def _load_json(path: Path) -> dict: + try: + if not path.exists(): + return {} + with open(path, "r") as f: + return json.load(f) + except (OSError, ValueError): + return {} + + +def _load_entries(path: Path) -> list[dict]: + return _load_json(path).get("entries", []) diff --git a/modules/security/cryptojacking_iocs/manifest.json b/modules/security/cryptojacking_iocs/manifest.json new file mode 100644 index 0000000..864dc52 --- /dev/null +++ b/modules/security/cryptojacking_iocs/manifest.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "last_updated": "2026-08-03", + "source": "Multiverse Device Rescue IOC Database", + "description": "Indicators of compromise for cryptojacking (unauthorised cryptocurrency mining) detection modules" +} diff --git a/modules/security/stalkerware_scan/__init__.py b/modules/security/stalkerware_scan/__init__.py new file mode 100644 index 0000000..33a0787 --- /dev/null +++ b/modules/security/stalkerware_scan/__init__.py @@ -0,0 +1,416 @@ +"""Look for software installed to watch the person using this computer. + +There are three overlapping things here and they need to be told apart, not +lumped together: + +- Stalkerware: sold for covertly monitoring another adult. Its presence on a + personal machine is the finding. +- Workplace and parental monitoring: legitimate products in the setting they + were designed for, routinely repurposed to control a partner or an adult + family member. Reported with the context needed to judge it. +- Remote access tools: ordinary IT software that happens to give someone else + full control of the machine. Reported so it can be accounted for, not + because it is malicious. + +The safety guidance in ``fix()`` matters as much as the detection. For someone +being monitored by a person they live with, removing the software is not +automatically the right first move: it tells the person watching that they +have been found out. That warning comes before any removal instruction. +""" + +import json +import re +import subprocess +from pathlib import Path + +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase +from rescue.runtime import content_file + +DATA_FILE = content_file("modules/security/stalkerware_scan/data/known_stalkerware.json") + +_COMMAND_TIMEOUT = 20 +_MAX_FILES_PER_LOCATION = 300 + +_SEVERITY_BY_NAME = { + "critical": Severity.CRITICAL, + "warning": Severity.WARNING, + "info": Severity.INFO, +} + +_DARWIN_APP_DIRS = [ + "/Applications", + "~/Applications", +] + +_DARWIN_PERSISTENCE_DIRS = [ + "~/Library/LaunchAgents", + "/Library/LaunchAgents", + "/Library/LaunchDaemons", +] + +_WIN_UNINSTALL_KEYS = [ + r"HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall", + r"HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall", +] + +_SAFETY_WARNING = ( + "Before removing anything: if the person who may have installed this lives " + "with you, or has ever frightened you, removing it can be dangerous. " + "Monitoring software usually tells whoever set it up when it stops " + "reporting, and that can escalate the situation. Talk to a domestic abuse " + "advocate first — they can help you plan the order in which to do this " + "safely.\n\n" + " US: National Domestic Violence Hotline, 1-800-799-7233, or text START " + "to 88788 (thehotline.org)\n" + " US: NNEDV Safety Net, techsafety.org — specialists in " + "technology-facilitated abuse\n" + " UK: National Domestic Abuse Helpline, 0808 2000 247; tech abuse support " + "at refugetechsafety.org\n" + " Australia: 1800RESPECT, 1800 737 732\n" + " Elsewhere: the Coalition Against Stalkerware (stopstalkerware.org) " + "lists services by country\n\n" + "Do not look these up from this device if you can avoid it — browsing " + "history is one of the things monitoring software reports." +) + + +class Module(ModuleBase): + name = "stalkerware_scan" + category = "security" + platforms = [Platform.DARWIN, Platform.WIN32, Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 82 + depends_on = [] + estimated_duration = "20s" + + def check(self, profile: SystemProfile) -> CheckResult: + entries = _load_known_stalkerware() + if not entries: + return CheckResult( + module_name=self.name, + error="Stalkerware indicator data could not be loaded.", + ) + + platform = profile.platform.value + applicable = [e for e in entries if platform in e.get("platforms", [])] + + observations: list[tuple[dict, str, str]] = [] + observations.extend(self._scan_processes(profile, applicable)) + observations.extend(self._scan_installed_software(profile, applicable)) + + if profile.platform == Platform.DARWIN: + observations.extend(self._scan_darwin_paths(applicable)) + elif profile.platform == Platform.WIN32: + observations.extend(self._scan_windows_uninstall_entries(applicable)) + + return CheckResult( + module_name=self.name, findings=self._to_findings(observations) + ) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + """Guidance only, and safety guidance first.""" + actions: list[Action] = [] + if not findings.findings: + return FixResult(module_name=self.name, actions=actions) + + categories = {f.data.get("stalkerware_category") for f in findings.findings} + + if "stalkerware" in categories: + actions.append( + Action( + title="Read this before removing the monitoring software", + description=_SAFETY_WARNING, + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + actions.append( + Action( + title="Use a different device for anything sensitive", + description=( + "Assume everything typed on this computer — passwords, " + "messages, searches, plans — has been seen. Until it is " + "cleaned up, use a device the other person has never had " + "physical access to, on a network they do not control, for " + "anything that matters. Do not change your important passwords " + "from this computer: the monitoring software would capture the " + "new ones as you type them." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + actions.append( + Action( + title="Preserve evidence before you delete anything", + description=( + "Monitoring another adult without consent is a crime in many " + "places, and the installed software is the evidence. " + "Photograph the screen showing the software, note the date, " + "and keep this scan's output. Deleting first leaves nothing to " + "show a police officer, a lawyer, or a judge." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + + for finding in findings.findings: + name = finding.data.get("stalkerware_name", "the software") + category = finding.data.get("stalkerware_category") + location = finding.data.get("location", "this machine") + + if category == "stalkerware": + actions.append( + Action( + title=f"Remove {name} when it is safe to do so", + description=( + f"{name} was found at {location}. When you have a safety " + "plan in place:\n" + "1. Uninstall it through the normal uninstaller if one " + "exists — monitoring software often reinstalls itself if " + "only the visible files are deleted.\n" + "2. Check for a second copy: these products commonly " + "install both a background service and a startup entry.\n" + "3. Change every account password afterwards, from a " + "different device.\n" + "4. Turn on two-factor authentication for email first, " + "then everything else.\n" + "5. The most reliable cleanup for a machine that has been " + "monitored is a full operating-system reinstall. Consider " + "it if anything about the machine still feels wrong." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + data={"software": name}, + ) + ) + elif category in {"employee_monitoring", "parental_control"}: + actions.append( + Action( + title=f"Decide whether {name} belongs on this computer", + description=( + f"{name} was found at {location}. This is a legitimate " + "product, so the question is not whether it is malware but " + "whether it is supposed to be here:\n" + "- On a computer owned by an employer or school, it is " + "expected. It still means work can see activity on it, so " + "keep personal accounts off this machine.\n" + "- On a personal computer nobody told you about, someone " + "set it up to watch what you do. Treat it the same way as " + "any other monitoring software." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"software": name}, + ) + ) + else: + actions.append( + Action( + title=f"Account for the remote access tool {name}", + description=( + f"{name} was found at {location}. Remote access software " + "lets someone else see and control this screen. Check:\n" + "- Did you or someone you trust install it, and is it still " + "needed? If not, uninstall it.\n" + "- If it stays, open its settings and check whether " + "unattended access is enabled and which accounts or " + "devices are on its allow list. Remove anything you do not " + "recognise, and change its password.\n" + "- Software left behind after a 'tech support' phone call " + "should be removed, and the accounts used on this machine " + "should have their passwords changed." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + data={"software": name}, + ) + ) + + return FixResult(module_name=self.name, actions=actions) + + # ---------------- scanners ---------------- + + def _scan_processes( + self, profile: SystemProfile, entries: list[dict] + ) -> list[tuple[dict, str, str]]: + observations = [] + for proc in profile.processes: + haystack = f"{proc.name} {proc.command}".lower() + entry = _match(haystack, entries) + if entry is not None: + observations.append( + (entry, "running process", f"{proc.name} (pid {proc.pid})") + ) + return observations + + def _scan_installed_software( + self, profile: SystemProfile, entries: list[dict] + ) -> list[tuple[dict, str, str]]: + observations = [] + for software in profile.installed_software: + entry = _match(software.lower(), entries) + if entry is not None: + observations.append((entry, "installed software", software)) + return observations + + def _scan_darwin_paths(self, entries: list[dict]) -> list[tuple[dict, str, str]]: + observations = [] + for directory in _DARWIN_APP_DIRS: + for path in _bounded_entries(directory): + entry = _match(path.name.lower(), entries) + if entry is not None: + observations.append((entry, "installed application", str(path))) + for directory in _DARWIN_PERSISTENCE_DIRS: + for path in _bounded_entries(directory): + if path.suffix != ".plist": + continue + entry = _match(path.name.lower(), entries) + if entry is not None: + observations.append((entry, "startup item", str(path))) + return observations + + def _scan_windows_uninstall_entries( + self, entries: list[dict] + ) -> list[tuple[dict, str, str]]: + observations = [] + for key in _WIN_UNINSTALL_KEYS: + output = _run(["reg", "query", key, "/s", "/v", "DisplayName"]) + for line in output.splitlines(): + if "DisplayName" not in line: + continue + parts = line.split("REG_SZ") + display_name = parts[-1].strip() if len(parts) > 1 else "" + if not display_name: + continue + entry = _match(display_name.lower(), entries) + if entry is not None: + observations.append((entry, "installed program", display_name)) + return observations + + # ---------------- findings ---------------- + + def _to_findings( + self, observations: list[tuple[dict, str, str]] + ) -> list[Finding]: + findings: list[Finding] = [] + seen: set[tuple[str, str]] = set() + + for entry, where, location in observations: + key = (entry["name"], location) + if key in seen: + continue + seen.add(key) + + category = entry.get("category", "stalkerware") + severity = _SEVERITY_BY_NAME.get( + entry.get("severity", "warning"), Severity.WARNING + ) + findings.append( + Finding( + title=f"{entry['name']} found as {where}", + description=( + f"{entry['description']}\n\n" + f"Found as {where}: {location}." + + ( + "\n\nThis product is sold for monitoring another person " + "without their knowledge. If nobody told you it was here, " + "someone installed it to watch what you do on this " + "computer." + if category == "stalkerware" + else "" + ) + ), + severity=severity, + category=self.category, + data={ + "check": "monitoring_software_found", + "stalkerware_name": entry["name"], + "stalkerware_category": category, + "detected_as": where, + "location": location, + "confidence": "high" if category == "stalkerware" else "medium", + }, + ) + ) + return findings + + +# ---------------- helpers ---------------- + + +def _load_known_stalkerware() -> list[dict]: + try: + with open(DATA_FILE) as f: + data = json.load(f) + except (OSError, ValueError): + return [] + entries = data.get("entries", []) + return entries if isinstance(entries, list) else [] + + +def _match(haystack: str, entries: list[dict]) -> dict | None: + for entry in entries: + if _pattern_regex(entry["pattern"]).search(haystack) is not None: + return entry + return None + + +def _pattern_regex(pattern: str) -> re.Pattern: + """Compile a product name into a boundary-aware matcher. + + Several product names are short, ordinary words ("bark", "aobo"), so plain + substring matching would accuse people over filenames like "barkeeper.app". + Requiring a non-alphanumeric character on both sides keeps punctuation and + path separators as boundaries while rejecting matches inside longer words. + """ + cached = _PATTERN_CACHE.get(pattern) + if cached is None: + cached = re.compile( + rf"(? list[Path]: + try: + base = Path(directory).expanduser() + if not base.is_dir(): + return [] + return sorted(base.iterdir())[:_MAX_FILES_PER_LOCATION] + except OSError: + return [] + + +def _run(command: list[str]) -> str: + try: + result = subprocess.run( + command, capture_output=True, text=True, timeout=_COMMAND_TIMEOUT + ) + except (OSError, subprocess.SubprocessError): + return "" + return result.stdout or "" diff --git a/modules/security/stalkerware_scan/data/known_stalkerware.json b/modules/security/stalkerware_scan/data/known_stalkerware.json new file mode 100644 index 0000000..2b8db24 --- /dev/null +++ b/modules/security/stalkerware_scan/data/known_stalkerware.json @@ -0,0 +1,391 @@ +{ + "version": "1.0.0", + "last_updated": "2026-08-03", + "note": "Categories matter more than the raw list. 'stalkerware' is software sold for covert monitoring of another adult; 'employee_monitoring' and 'parental_control' are legitimate products that are routinely repurposed for abuse; 'remote_access' tools are ordinary IT software that gives someone else full control of the machine. Only the first category is inherently a finding — the rest are reported with the context needed to judge them.", + "entries": [ + { + "pattern": "flexispy", + "name": "FlexiSPY", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32", "linux"], + "description": "Covert monitoring software that captures messages, calls, location, and ambient audio, and is marketed explicitly for monitoring partners." + }, + { + "pattern": "mspy", + "name": "mSpy", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring suite that records keystrokes, messages, browsing, and location, and hides itself from the person being monitored." + }, + { + "pattern": "hoverwatch", + "name": "Hoverwatch", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Hidden monitoring software that logs keystrokes, screenshots, and webcam images without notifying the user." + }, + { + "pattern": "cocospy", + "name": "Cocospy", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring service marketed for tracking partners and family members." + }, + { + "pattern": "umobix", + "name": "uMobix", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring service capturing messages, calls, and screen activity." + }, + { + "pattern": "spyzie", + "name": "Spyzie", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring service marketed for silent tracking of another person's device." + }, + { + "pattern": "thetruthspy", + "name": "TheTruthSpy", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring service; its own servers have leaked victim data repeatedly." + }, + { + "pattern": "xnspy", + "name": "XNSPY", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring suite advertised for partner and employee surveillance." + }, + { + "pattern": "ikeymonitor", + "name": "iKeyMonitor", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Keylogger and screen recorder marketed for covert monitoring." + }, + { + "pattern": "clevguard", + "name": "ClevGuard / KidsGuard", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "KidsGuard Pro records keystrokes, screenshots, and messages while hiding its presence." + }, + { + "pattern": "kidsguard", + "name": "KidsGuard Pro", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring product that hides its icon and runs invisibly." + }, + { + "pattern": "spyrix", + "name": "Spyrix", + "category": "stalkerware", + "severity": "critical", + "platforms": ["win32"], + "description": "Keylogger that streams keystrokes, screenshots, and webcam captures to a remote dashboard." + }, + { + "pattern": "refog", + "name": "Refog Personal Monitor", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Hidden keylogger and screen recorder sold for monitoring family members." + }, + { + "pattern": "ardamax", + "name": "Ardamax Keylogger", + "category": "stalkerware", + "severity": "critical", + "platforms": ["win32"], + "description": "Stealth keylogger that runs invisibly and emails captured keystrokes." + }, + { + "pattern": "spyagent", + "name": "Spytech SpyAgent", + "category": "stalkerware", + "severity": "critical", + "platforms": ["win32"], + "description": "Stealth monitoring suite recording keystrokes, screenshots, and application use." + }, + { + "pattern": "realtime-spy", + "name": "Realtime-Spy", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Remote monitoring product that installs silently and reports to a web dashboard." + }, + { + "pattern": "pctattletale", + "name": "PC Tattletale", + "category": "stalkerware", + "severity": "critical", + "platforms": ["win32"], + "description": "Screen recording monitoring product; its recordings have been exposed publicly by its own misconfiguration." + }, + { + "pattern": "webwatcher", + "name": "WebWatcher", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring product recording messages, browsing, and screenshots." + }, + { + "pattern": "elite keylogger", + "name": "Elite Keylogger", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Stealth keylogger that hides from the process list and task manager." + }, + { + "pattern": "perfect keylogger", + "name": "Perfect Keylogger", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Stealth keylogger with remote log delivery." + }, + { + "pattern": "aobo", + "name": "Aobo Keylogger", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Invisible keylogger for macOS and Windows sold for covert monitoring." + }, + { + "pattern": "kidlogger", + "name": "KidLogger", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32", "linux"], + "description": "Monitoring software that records keystrokes, applications, and screenshots in the background." + }, + { + "pattern": "actual keylogger", + "name": "Actual Keylogger", + "category": "stalkerware", + "severity": "critical", + "platforms": ["win32"], + "description": "Hidden keystroke recorder." + }, + { + "pattern": "teramind", + "name": "Teramind", + "category": "employee_monitoring", + "severity": "warning", + "platforms": ["darwin", "win32", "linux"], + "description": "Workplace monitoring agent recording screen, keystrokes, and application use. Expected on a company-managed computer; on a personal machine it means someone is watching everything done on it." + }, + { + "pattern": "activtrak", + "name": "ActivTrak", + "category": "employee_monitoring", + "severity": "warning", + "platforms": ["darwin", "win32"], + "description": "Workplace activity monitoring agent capturing screenshots and application use." + }, + { + "pattern": "veriato", + "name": "Veriato / Cerberus", + "category": "employee_monitoring", + "severity": "warning", + "platforms": ["darwin", "win32"], + "description": "Employee monitoring suite with covert recording modes." + }, + { + "pattern": "interguard", + "name": "InterGuard", + "category": "employee_monitoring", + "severity": "warning", + "platforms": ["darwin", "win32"], + "description": "Employee monitoring agent that can run invisibly to the user." + }, + { + "pattern": "staffcop", + "name": "StaffCop", + "category": "employee_monitoring", + "severity": "warning", + "platforms": ["win32", "linux"], + "description": "Employee monitoring suite recording screen, keystrokes, and files." + }, + { + "pattern": "kickidler", + "name": "Kickidler", + "category": "employee_monitoring", + "severity": "warning", + "platforms": ["darwin", "win32", "linux"], + "description": "Employee monitoring agent with continuous screen recording." + }, + { + "pattern": "hubstaff", + "name": "Hubstaff", + "category": "employee_monitoring", + "severity": "info", + "platforms": ["darwin", "win32", "linux"], + "description": "Time-tracking agent that can capture periodic screenshots. Normal on a work machine when the person knows it is installed." + }, + { + "pattern": "timedoctor", + "name": "Time Doctor", + "category": "employee_monitoring", + "severity": "info", + "platforms": ["darwin", "win32", "linux"], + "description": "Time-tracking agent with optional screenshot capture." + }, + { + "pattern": "qustodio", + "name": "Qustodio", + "category": "parental_control", + "severity": "info", + "platforms": ["darwin", "win32"], + "description": "Parental control software. Legitimate for a child's device; on an adult's own computer it gives whoever set it up a log of their activity." + }, + { + "pattern": "net nanny", + "name": "Net Nanny", + "category": "parental_control", + "severity": "info", + "platforms": ["darwin", "win32"], + "description": "Parental control and content filtering software with activity reporting." + }, + { + "pattern": "bark", + "name": "Bark", + "category": "parental_control", + "severity": "info", + "platforms": ["darwin", "win32"], + "description": "Parental monitoring service that reports messages and browsing to an account owner." + }, + { + "pattern": "teamviewer", + "name": "TeamViewer", + "category": "remote_access", + "severity": "info", + "platforms": ["darwin", "win32", "linux"], + "description": "Remote control software. Useful and common — and it gives whoever holds the credentials complete control of the machine, including watching the screen. Check whether unattended access is enabled and who is on its trusted-devices list." + }, + { + "pattern": "anydesk", + "name": "AnyDesk", + "category": "remote_access", + "severity": "info", + "platforms": ["darwin", "win32", "linux"], + "description": "Remote control software frequently installed by tech-support scammers and left behind afterwards." + }, + { + "pattern": "screenconnect", + "name": "ScreenConnect / ConnectWise Control", + "category": "remote_access", + "severity": "warning", + "platforms": ["darwin", "win32", "linux"], + "description": "Remote support agent very commonly abused for persistent unauthorised access, because it installs as a service and reconnects on its own." + }, + { + "pattern": "netsupport", + "name": "NetSupport Manager", + "category": "remote_access", + "severity": "warning", + "platforms": ["darwin", "win32"], + "description": "Remote control product routinely repackaged as malware ('NetSupport RAT') and installed silently." + }, + { + "pattern": "remoteutilities", + "name": "Remote Utilities", + "category": "remote_access", + "severity": "warning", + "platforms": ["win32"], + "description": "Remote access product commonly used for persistent covert access." + }, + { + "pattern": "ammyy", + "name": "Ammyy Admin", + "category": "remote_access", + "severity": "warning", + "platforms": ["win32"], + "description": "Remote access tool strongly associated with support scams and unauthorised access." + }, + { + "pattern": "dwagent", + "name": "DWAgent", + "category": "remote_access", + "severity": "warning", + "platforms": ["darwin", "win32", "linux"], + "description": "Unattended remote access agent that runs as a service." + }, + { + "pattern": "logmein", + "name": "LogMeIn", + "category": "remote_access", + "severity": "info", + "platforms": ["darwin", "win32"], + "description": "Unattended remote access service." + }, + { + "pattern": "splashtop", + "name": "Splashtop", + "category": "remote_access", + "severity": "info", + "platforms": ["darwin", "win32", "linux"], + "description": "Remote desktop service with unattended access." + }, + { + "pattern": "realvnc", + "name": "RealVNC", + "category": "remote_access", + "severity": "info", + "platforms": ["darwin", "win32", "linux"], + "description": "Remote screen access server." + }, + { + "pattern": "tightvnc", + "name": "TightVNC", + "category": "remote_access", + "severity": "warning", + "platforms": ["win32", "linux"], + "description": "VNC server, often installed silently to provide hidden screen access." + }, + { + "pattern": "ultravnc", + "name": "UltraVNC", + "category": "remote_access", + "severity": "warning", + "platforms": ["win32"], + "description": "VNC server, often installed silently to provide hidden screen access." + }, + { + "pattern": "radmin", + "name": "Radmin", + "category": "remote_access", + "severity": "warning", + "platforms": ["win32"], + "description": "Remote administration server providing full control of the machine." + }, + { + "pattern": "atera", + "name": "Atera agent", + "category": "remote_access", + "severity": "warning", + "platforms": ["darwin", "win32"], + "description": "Remote management agent; abused by intruders because it is signed, silent, and grants full remote control." + } + ] +} diff --git a/modules/security/win_crypto_miner_detect/__init__.py b/modules/security/win_crypto_miner_detect/__init__.py new file mode 100644 index 0000000..f4df1ea --- /dev/null +++ b/modules/security/win_crypto_miner_detect/__init__.py @@ -0,0 +1,424 @@ +"""Windows counterpart to the macOS ``crypto_miner_detect`` module. + +Cryptojacking is overwhelmingly a Windows problem — bundled with cracked +software, game "boosters", and browser installers — but the toolkit only had a +macOS miner detector. This module looks at the three things a live miner +cannot hide: its process name, its command line (which has to name a pool and +a wallet), and its network connection to a mining pool. +""" + +import csv +import io +import re +import subprocess + +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase +from rescue.runtime import content_directory, load_content_module + +_IOC_DIR = content_directory("modules/security/cryptojacking_iocs") +_IOC_LOADER_KEY = "rescue_cryptojacking_iocs_loader" + +_COMMAND_TIMEOUT = 30 + +# Percent processor time (summed across cores by the performance counter) at +# which a non-allowlisted process becomes worth mentioning. Deliberately high: +# a CPU-only signal is a lead, never a conclusion. +_HIGH_CPU_THRESHOLD = 70.0 + +_MONERO_ADDRESS = re.compile(r"\b[48][1-9A-HJ-NP-Za-km-z]{94}\b") + +_MINER_ARGUMENTS = [ + re.compile(r"stratum\+(tcp|ssl)://", re.IGNORECASE), + re.compile(r"--donate-level", re.IGNORECASE), + re.compile(r"--cpu-max-threads-hint", re.IGNORECASE), + re.compile(r"--randomx", re.IGNORECASE), +] + +# Windows processes that legitimately spike to high CPU. Matched on the +# performance-counter instance name, which has no .exe suffix. +_KNOWN_HIGH_CPU_PROCESSES = { + "system", + "svchost", + "msmpeng", + "searchindexer", + "searchprotocolhost", + "tiworker", + "trustedinstaller", + "wuauclt", + "compattelrunner", + "dwm", + "explorer", + "runtimebroker", + "chrome", + "msedge", + "firefox", + "code", + "devenv", + "msbuild", + "node", + "python", + "java", + "ffmpeg", + "handbrake", + "blender", + "obs64", + "photoshop", + "premiere pro", + "unrealeditor", + "unity", +} + + +def _load_iocs(): + """Load the shared cryptojacking IOC database, or None if unavailable.""" + loader = load_content_module( + "modules/security/cryptojacking_iocs/loader.py", _IOC_LOADER_KEY + ) + if loader is None: + return None + try: + return loader.load_cryptojacking_iocs(_IOC_DIR) + except Exception: + return None + + +_IOCS = _load_iocs() + + +class Module(ModuleBase): + name = "win_crypto_miner_detect" + category = "security" + platforms = [Platform.WIN32] + risk_level = RiskLevel.SAFE + priority = 60 + depends_on = [] + estimated_duration = "20s" + + def check(self, profile: SystemProfile) -> CheckResult: + processes = self._get_processes() + findings = self._check_processes(processes) + findings.extend(self._check_pool_connections(processes)) + findings.extend(self._check_high_cpu()) + return CheckResult(module_name=self.name, findings=findings) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + """Guidance only — never terminates processes or deletes files.""" + actions: list[Action] = [] + + confirmed = [ + f for f in findings.findings if f.data.get("confidence") == "high" + ] + leads = [f for f in findings.findings if f.data.get("confidence") != "high"] + + for finding in confirmed: + process = finding.data.get("process", "unknown") + pid = finding.data.get("pid") + actions.append( + Action( + title=f"Stop and remove the miner {process} (PID {pid})", + description=( + f"{finding.description}\n\n" + "1. Note the pool and wallet address shown above before you " + "delete anything — that is the evidence.\n" + "2. Open Task Manager > Details, right-click the process, and " + "choose 'Open file location' so you know what to delete.\n" + "3. End the process, then delete the file.\n" + "4. Run the crypto_miner_persistence check: miners are almost " + "always restarted by a scheduled task or Run key, so the " + "process coming back is expected until you remove that too.\n" + "5. Run a full Microsoft Defender offline scan." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + data={"pid": pid, "process": process}, + ) + ) + + for finding in leads: + process = finding.data.get("process", "unknown") + actions.append( + Action( + title=f"Identify what {process} is before assuming the worst", + description=( + f"{finding.description}\n\n" + "High CPU on its own is not proof of mining — video encoding, " + "game shader compilation, backups, and Windows Update all look " + "like this. Check the publisher and file location in Task " + "Manager > Details. Treat it as a miner only if the file lives " + "in a temp/AppData folder, has no publisher, or you cannot " + "explain why it is running." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"process": process}, + ) + ) + + return FixResult(module_name=self.name, actions=actions) + + # ---------------- collection ---------------- + + def _get_processes(self) -> list[dict]: + """Return running processes with their command lines.""" + output = _run_powershell( + "Get-CimInstance Win32_Process | " + "Select-Object ProcessId,Name,CommandLine | " + "ConvertTo-Csv -NoTypeInformation" + ) + processes = [] + for row in _parse_csv(output): + pid = row.get("ProcessId", "").strip() + processes.append( + { + "pid": int(pid) if pid.isdigit() else 0, + "name": row.get("Name", "").strip(), + "command": (row.get("CommandLine") or "").strip(), + } + ) + return processes + + # ---------------- checks ---------------- + + def _check_processes(self, processes: list[dict]) -> list[Finding]: + findings: list[Finding] = [] + for proc in processes: + haystack = f"{proc['name']} {proc['command']}" + hit = self._classify(haystack) + if hit is None: + continue + findings.append( + Finding( + title=f"Cryptocurrency miner running: {proc['name']}", + description=( + f"Process {proc['name']} (PID {proc['pid']}) {hit['detail']}. " + "A miner uses your CPU or GPU to earn cryptocurrency for " + "whoever installed it, which is why the machine is hot, loud, " + "and slow." + ), + severity=hit["severity"], + category=self.category, + data={ + "check": "known_miner", + "pid": proc["pid"], + "process": proc["name"], + "command": proc["command"][:400], + "indicator": hit["indicator"], + "confidence": hit["confidence"], + "evidence": hit["evidence"], + }, + ) + ) + return findings + + def _check_pool_connections(self, processes: list[dict]) -> list[Finding]: + """Flag established connections to mining-pool ports.""" + if _IOCS is None or not _IOCS.pool_ports: + return [] + + output = _run(["netstat", "-ano"]) + if not output: + return [] + + by_pid = {proc["pid"]: proc for proc in processes} + findings: list[Finding] = [] + seen: set[tuple[int, int]] = set() + + for line in output.splitlines(): + parts = line.split() + if len(parts) < 5 or parts[0].upper() not in {"TCP", "UDP"}: + continue + remote = parts[2] + state = parts[3].upper() + pid_str = parts[4] + if not pid_str.isdigit() or state != "ESTABLISHED": + continue + port = _port_of(remote) + if port is None or port not in _IOCS.pool_ports: + continue + + pid = int(pid_str) + if (pid, port) in seen: + continue + seen.add((pid, port)) + + proc = by_pid.get(pid, {}) + process_name = proc.get("name", f"PID {pid}") + findings.append( + Finding( + title=f"Connection to a mining pool port from {process_name}", + description=( + f"{process_name} (PID {pid}) holds an established connection to " + f"{remote}. Port {port} is a standard Stratum mining-pool port. " + "Unless someone deliberately set up mining on this machine, " + "this is a miner reporting work to someone else's wallet." + ), + severity=Severity.CRITICAL, + category=self.category, + data={ + "check": "mining_pool_connection", + "pid": pid, + "process": process_name, + "remote": remote, + "port": port, + "confidence": "high", + }, + ) + ) + return findings + + def _check_high_cpu(self) -> list[Finding]: + output = _run_powershell( + "Get-CimInstance Win32_PerfFormattedData_PerfProc_Process | " + "Where-Object { $_.Name -ne '_Total' -and $_.Name -ne 'Idle' } | " + "Select-Object Name,IDProcess,PercentProcessorTime | " + "ConvertTo-Csv -NoTypeInformation" + ) + findings: list[Finding] = [] + for row in _parse_csv(output): + name = row.get("Name", "").strip() + pid_str = row.get("IDProcess", "").strip() + percent_str = row.get("PercentProcessorTime", "").strip() + if not name or not percent_str: + continue + try: + percent = float(percent_str) + except ValueError: + continue + if percent < _HIGH_CPU_THRESHOLD: + continue + if _is_known_high_cpu(name): + continue + findings.append( + Finding( + title=f"Sustained high CPU usage by {name}", + description=( + f"{name} (PID {pid_str or 'unknown'}) is using {percent:.0f}% of " + "processor time and is not a process Windows normally pegs the " + "CPU with. Silent mining looks exactly like this. It may equally " + "be legitimate heavy work, so confirm what the program is before " + "removing it." + ), + severity=Severity.WARNING, + category=self.category, + data={ + "check": "high_cpu_process", + "pid": int(pid_str) if pid_str.isdigit() else 0, + "process": name, + "cpu_percent": percent, + "confidence": "low", + }, + ) + ) + return findings + + # ---------------- classification ---------------- + + def _classify(self, text: str) -> dict | None: + lowered = text.lower() + + wallet = _MONERO_ADDRESS.search(text) + if wallet is not None: + return { + "indicator": "monero_wallet_address", + "severity": Severity.CRITICAL, + "confidence": "high", + "detail": ( + "was started with a Monero wallet address on its command line " + f"({wallet.group(0)[:12]}…{wallet.group(0)[-6:]}) — the account being paid" + ), + "evidence": wallet.group(0), + } + + for pattern in _MINER_ARGUMENTS: + match = pattern.search(text) + if match is not None: + return { + "indicator": "miner_arguments", + "severity": Severity.CRITICAL, + "confidence": "high", + "detail": ( + f"was started with mining arguments ({match.group(0).strip()})" + ), + "evidence": match.group(0).strip(), + } + + if _IOCS is not None: + for pool in _IOCS.pools: + if pool.domain.lower() in lowered: + return { + "indicator": "mining_pool", + "severity": Severity.CRITICAL, + "confidence": "high", + "detail": f"references the mining pool {pool.domain}", + "evidence": pool.domain, + } + for miner in _IOCS.miners_for("win32"): + if miner.pattern.lower() in lowered: + return { + "indicator": "known_miner", + "severity": ( + Severity.CRITICAL + if miner.severity == "critical" + else Severity.WARNING + ), + "confidence": "high" if miner.severity == "critical" else "medium", + "detail": f"is {miner.name} — {miner.description}", + "evidence": miner.pattern, + } + return None + + +# ---------------- helpers ---------------- + + +def _run(command: list[str]) -> str: + try: + result = subprocess.run( + command, capture_output=True, text=True, timeout=_COMMAND_TIMEOUT + ) + except (OSError, subprocess.SubprocessError): + return "" + return result.stdout or "" + + +def _run_powershell(script: str) -> str: + return _run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script] + ) + + +def _parse_csv(output: str) -> list[dict]: + if not output.strip(): + return [] + try: + return list(csv.DictReader(io.StringIO(output))) + except csv.Error: + return [] + + +def _port_of(address: str) -> int | None: + if ":" not in address: + return None + port_str = address.rsplit(":", 1)[-1] + return int(port_str) if port_str.isdigit() else None + + +def _is_known_high_cpu(name: str) -> bool: + # Performance-counter instance names are suffixed for duplicates + # ("chrome#3"), so compare on the base name. + base = name.split("#")[0].lower().removesuffix(".exe") + return base in _KNOWN_HIGH_CPU_PROCESSES diff --git a/modules/security/win_malware_indicators/__init__.py b/modules/security/win_malware_indicators/__init__.py index dc33dd8..ef9e7e1 100644 --- a/modules/security/win_malware_indicators/__init__.py +++ b/modules/security/win_malware_indicators/__init__.py @@ -67,13 +67,20 @@ def fix(self, findings: CheckResult, mode: Mode) -> FixResult: continue if check == "malware_registry": + # Bound outside the f-string: an expression containing a + # backslash is a syntax error before Python 3.12, and this + # package supports 3.11. + registry_path = finding.data.get( + "registry_path", + "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", + ) action = Action( title="Remove malware registry entry (manual)", description=( "MANUAL REMEDIATION REQUIRED:\n" f"1. Open Registry Editor (regedit.exe) or PowerShell as Administrator\n" f"2. Navigate to the registry path and delete the suspicious entry:\n" - f" {finding.data.get('registry_path', 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run')}\n" + f" {registry_path}\n" f"3. Restart the system in Safe Mode to ensure the malware does not run\n" f"4. Run a full antivirus scan with Windows Defender or another reputable AV\n" f"5. Consider using specialized malware removal tools if infection persists" diff --git a/profiles/home_network_intrusion.yaml b/profiles/home_network_intrusion.yaml new file mode 100644 index 0000000..d5c0681 --- /dev/null +++ b/profiles/home_network_intrusion.yaml @@ -0,0 +1,40 @@ +name: home_network_intrusion +display_name: "Home Network Intrusion & Cryptojacking Response" +description: > + Response for a household whose Wi-Fi has been broken into, and for the + cryptojacking and monitoring software that tends to arrive with it. + Inventories what is actually on the local network, checks for traffic + interception, audits the router's exposed surface, and searches every + device for mining and monitoring software — including the startup + entries that quietly bring a miner back after it is killed. Ordered so + that the network is reclaimed before the devices are cleaned, because a + cleaned device rejoining a compromised network is compromised again. +modules: + include: + # The network: who is on it, is anyone intercepting, what is the router + # offering to whoever is connected. + - lan_device_inventory + - arp_spoof_check + - router_security_audit + - wifi_security_audit + # Cryptojacking: what is running now, what restarts it, and the browser. + - crypto_miner_detect + - win_crypto_miner_detect + - crypto_miner_persistence + - browser_cryptojacking_check + - process_scanner + # Who else has access to the machine itself. + - stalkerware_scan + - remote_login_check + - win_remote_access_audit + - suspicious_connections + - open_ports_scan + exclude: [] +module_config: + # Set expected_device_count to the number of devices the household can + # actually name — phones, laptops, TVs, speakers, consoles, printers, and + # every smart plug and doorbell. known_macs can list hardware addresses + # already accounted for, so the inventory only flags the rest. + lan_device_inventory: {} +guides: + - home_network_intrusion diff --git a/profiles/identity_theft_recovery.yaml b/profiles/identity_theft_recovery.yaml new file mode 100644 index 0000000..5c12ea7 --- /dev/null +++ b/profiles/identity_theft_recovery.yaml @@ -0,0 +1,35 @@ +name: identity_theft_recovery +display_name: "Identity Theft Recovery" +description: > + Step-by-step recovery for someone whose identity has been stolen: credit + and account freezes, the official reports that unlock legal protections, + disputing fraudulent accounts, and the long tail of monitoring afterwards. + Most of this work happens with banks, credit bureaus, and government + agencies rather than on the computer, so the guide is the substance and the + modules only answer one question — whether the device being used to do the + recovery is itself leaking credentials. The walkthrough doubles as a + checklist: run `rescue guide identity_theft_recovery` to see what is done + and what is outstanding, and mark steps off as you go. +modules: + include: + # Is this device the leak? Doing recovery from a compromised machine + # hands the new passwords straight back. + - stalkerware_scan + - keylogger_indicators + - malware_scan_indicators + - win_malware_indicators + - suspicious_processes + - win_suspicious_processes + - process_scanner + # Credential and session theft happens in the browser more often than + # anywhere else. + - browser_extension_audit + - browser_hijack_check + - certificate_trust_audit + # Somebody else still being logged in is the simplest explanation of all. + - remote_login_check + - win_remote_access_audit + exclude: [] +module_config: {} +guides: + - identity_theft_recovery diff --git a/rescue/runtime.py b/rescue/runtime.py index 3fc44a7..b7ad9bd 100644 --- a/rescue/runtime.py +++ b/rescue/runtime.py @@ -2,10 +2,12 @@ from __future__ import annotations +import importlib.util import os import sys import sysconfig from pathlib import Path +from types import ModuleType ASSET_DIRECTORY_NAME = "multiverse-device-rescue" @@ -48,6 +50,40 @@ def content_directory(name: str) -> Path: return bundled_root() / name +def load_content_module(relative_path: str | Path, key: str) -> ModuleType | None: + """Import a helper shipped alongside the modules, by path. + + Modules ship as data files rather than as an importable package (see + `setup.py`), so a shared helper such as an IOC loader cannot be reached + with a normal `import`. Loading it by path works in a source checkout, a + pip install, and a PyInstaller bundle alike. + + Returns None when the helper is missing or fails to execute; every caller + is expected to degrade to "this check is unavailable" rather than raise. + """ + existing = sys.modules.get(key) + if existing is not None: + return existing + + path = content_file(relative_path) + try: + spec = importlib.util.spec_from_file_location(key, path) + if spec is None or spec.loader is None: + return None + module = importlib.util.module_from_spec(spec) + # Registered before execution: dataclasses defined in the helper look + # their own module up in sys.modules while being constructed. + sys.modules[key] = module + try: + spec.loader.exec_module(module) + except Exception: + del sys.modules[key] + raise + return module + except Exception: + return None + + def content_file(relative_path: str | Path) -> Path: """Resolve an updateable data file without allowing updated Python code.""" relative = Path(relative_path) diff --git a/rescue/security/integrity_manifest.json b/rescue/security/integrity_manifest.json index af55f2d..f3cc989 100644 --- a/rescue/security/integrity_manifest.json +++ b/rescue/security/integrity_manifest.json @@ -23,7 +23,7 @@ "profiler/windows.py": "b3fdae0d8671b5a669c0af05f5a50f7ffb1b61520f45280f82e16a0f96c83d45", "profiles.py": "4268b3e8595daf37970776e14e13790a3966fd55fc0caca01aa11d9dda3e939a", "registry.py": "df8f118c46f109754a2579a4fbda91ba7b28d0b189dc3db5f66620f8e2c3cebd", - "runtime.py": "1e7afa49e96cdd648cb4467f5d87387320a832eeda9d1b8fabc1d4d93df4f618", + "runtime.py": "f025274c2e5374944dd80c4ae37d3e52c5ae6b3b253a8afa2e24bdab0b782121", "security/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "security/integrity.py": "e0d9cdc38fe6abf4c6c3ec046adb0e0e2c69f56f418ab94581c2b3f5e2edb7ad", "security/signers.py": "61033baaa6d54dbd662e6d5b8fc4ba4b427a8fe062b72e9e38669a30088dbb07", diff --git a/tests/test_cryptojacking_iocs.py b/tests/test_cryptojacking_iocs.py new file mode 100644 index 0000000..5d02c94 --- /dev/null +++ b/tests/test_cryptojacking_iocs.py @@ -0,0 +1,67 @@ +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.runtime import load_content_module + +IOC_DIR = ( + Path(__file__).parent.parent / "modules" / "security" / "cryptojacking_iocs" +) +LOADER = load_content_module( + "modules/security/cryptojacking_iocs/loader.py", "test_cryptojacking_iocs_loader" +) + + +def test_loader_is_importable_by_path(): + assert LOADER is not None + + +def test_database_loads_every_section(): + db = LOADER.load_cryptojacking_iocs(IOC_DIR) + assert db.version != "unknown" + assert db.miners + assert db.pools + assert db.pool_ports + assert db.browser_script_domains + assert db.browser_script_markers + assert db.browser_extensions + + +def test_platform_filtering(): + db = LOADER.load_cryptojacking_iocs(IOC_DIR) + linux_only = {m.pattern for m in db.miners_for("linux")} + windows_only = {m.pattern for m in db.miners_for("win32")} + + assert "kdevtmpfsi" in linux_only + assert "kdevtmpfsi" not in windows_only + assert "xmrig" in linux_only and "xmrig" in windows_only + + +def test_missing_directory_degrades_to_an_empty_database(): + db = LOADER.load_cryptojacking_iocs(Path("/does/not/exist")) + assert db.version == "unknown" + assert db.miners == [] + assert db.pools == [] + + +def test_data_files_are_well_formed(): + for name in ("known_miners.json", "known_pools.json", "browser_miners.json"): + with open(IOC_DIR / name) as f: + data = json.load(f) + assert data["version"] + + with open(IOC_DIR / "known_miners.json") as f: + for entry in json.load(f)["entries"]: + assert entry["pattern"] and entry["description"] + assert entry["severity"] in {"critical", "warning"} + assert set(entry["platforms"]) <= {"darwin", "linux", "win32"} + + +def test_pool_ports_are_plausible_stratum_ports(): + db = LOADER.load_cryptojacking_iocs(IOC_DIR) + assert 3333 in db.pool_ports + assert all(0 < port < 65536 for port in db.pool_ports) + # Ports shared with ordinary services would make every check noisy. + assert not {80, 443, 22, 53} & set(db.pool_ports) diff --git a/tests/test_home_network_intrusion_profile.py b/tests/test_home_network_intrusion_profile.py new file mode 100644 index 0000000..6742476 --- /dev/null +++ b/tests/test_home_network_intrusion_profile.py @@ -0,0 +1,103 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.guides import discover_guides +from rescue.profiles import ( + discover_profiles, + filter_modules_by_profile, + load_profile, + validate_profile_modules, +) +from rescue.registry import discover_modules + +PROJECT_ROOT = Path(__file__).parent.parent +PROFILE_PATH = PROJECT_ROOT / "profiles" / "home_network_intrusion.yaml" +GUIDES_DIR = PROJECT_ROOT / "guides" +MODULES_DIR = PROJECT_ROOT / "modules" + + +def test_profile_loads(): + profile = load_profile(PROFILE_PATH) + assert profile.name == "home_network_intrusion" + assert profile.display_name == "Home Network Intrusion & Cryptojacking Response" + assert profile.guides == ["home_network_intrusion"] + + +def test_profile_is_discovered_alongside_the_others(): + profiles = discover_profiles(PROJECT_ROOT / "profiles") + assert "home_network_intrusion" in profiles + + +def test_every_referenced_module_exists(): + profile = load_profile(PROFILE_PATH) + validate_profile_modules(profile, discover_modules(MODULES_DIR)) + + +def test_profile_covers_network_cryptojacking_and_monitoring(): + profile = load_profile(PROFILE_PATH) + matched = {m.name for m in filter_modules_by_profile(discover_modules(MODULES_DIR), profile)} + + # The network has to be reclaimable before the devices are cleaned. + assert {"lan_device_inventory", "arp_spoof_check", "router_security_audit"} <= matched + # Cryptojacking, including what restarts a miner after it is killed. + assert { + "crypto_miner_detect", + "win_crypto_miner_detect", + "crypto_miner_persistence", + "browser_cryptojacking_check", + } <= matched + # Who else has access to the machine. + assert {"stalkerware_scan", "remote_login_check"} <= matched + + +def test_guide_has_all_six_phases_in_order(): + guides = discover_guides(GUIDES_DIR, "home_network_intrusion") + assert [g.phase for g in guides] == [0, 1, 2, 3, 4, 5] + + +def test_guide_ends_with_reachable_help(): + guides = discover_guides(GUIDES_DIR, "home_network_intrusion") + resources = guides[-1] + assert "Resources" in resources.title + + body = "\n".join(step.body for step in resources.steps).lower() + # Abuse support comes first, because it changes the order of everything. + assert "1-800-799-7233" in body + assert "techsafety.org" in body + assert "accessnow.org/help" in body + assert "ic3.gov" in body + # And the warning that makes the rest of the list safe to use. + assert "gift card" in body + + +def test_guide_opens_with_the_safety_check_not_a_scan(): + guides = discover_guides(GUIDES_DIR, "home_network_intrusion") + phase_0 = next(g for g in guides if g.phase == 0) + assert phase_0.automatable_steps == [] + assert "safety" in phase_0.steps[0].title.lower() + + +def test_every_step_is_classified_as_automatable_or_human_only(): + for guide in discover_guides(GUIDES_DIR, "home_network_intrusion"): + classified = set(guide.automatable_steps) | set(guide.human_only_steps) + assert classified == {step.number for step in guide.steps} + assert not set(guide.automatable_steps) & set(guide.human_only_steps) + + +def test_automatable_steps_reference_modules_the_profile_actually_includes(): + profile = load_profile(PROFILE_PATH) + available = {m.name for m in filter_modules_by_profile(discover_modules(MODULES_DIR), profile)} + + referenced = set() + for guide in discover_guides(GUIDES_DIR, "home_network_intrusion"): + for step in guide.steps: + if not step.automatable: + continue + for module_name in available: + if module_name in step.body: + referenced.add(module_name) + + assert referenced, "automatable steps should name the modules they run" + assert referenced <= available diff --git a/tests/test_identity_theft_recovery_profile.py b/tests/test_identity_theft_recovery_profile.py new file mode 100644 index 0000000..ccdcb6e --- /dev/null +++ b/tests/test_identity_theft_recovery_profile.py @@ -0,0 +1,144 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.guides import discover_guides +from rescue.profiles import ( + discover_profiles, + filter_modules_by_profile, + load_profile, + validate_profile_modules, +) +from rescue.registry import discover_modules + +PROJECT_ROOT = Path(__file__).parent.parent +PROFILE_PATH = PROJECT_ROOT / "profiles" / "identity_theft_recovery.yaml" +GUIDES_DIR = PROJECT_ROOT / "guides" +MODULES_DIR = PROJECT_ROOT / "modules" + + +def _guides(): + return discover_guides(GUIDES_DIR, "identity_theft_recovery") + + +def test_profile_loads_and_is_discovered(): + profiles = discover_profiles(PROJECT_ROOT / "profiles") + assert "identity_theft_recovery" in profiles + + profile = load_profile(PROFILE_PATH) + assert profile.display_name == "Identity Theft Recovery" + assert profile.guides == ["identity_theft_recovery"] + + +def test_every_referenced_module_exists(): + profile = load_profile(PROFILE_PATH) + validate_profile_modules(profile, discover_modules(MODULES_DIR)) + + +def test_profile_answers_whether_the_device_is_leaking_credentials(): + profile = load_profile(PROFILE_PATH) + matched = { + m.name for m in filter_modules_by_profile(discover_modules(MODULES_DIR), profile) + } + assert {"keylogger_indicators", "stalkerware_scan"} <= matched + assert {"browser_extension_audit", "certificate_trust_audit"} <= matched + assert {"remote_login_check", "win_remote_access_audit"} <= matched + + +def test_guide_has_seven_phases_in_order(): + assert [g.phase for g in _guides()] == [0, 1, 2, 3, 4, 5, 6] + + +def test_resources_phase_lists_free_case_managed_help(): + resources = _guides()[-1] + assert "Resources" in resources.title + + body = "\n".join(step.body for step in resources.steps).lower() + for expected in ( + "idtheftcenter.org", # free advisors who stay with the case + "1-888-400-5530", + "1-877-908-3360", # AARP helpline, open to any age + "1-833-372-8311", # DOJ elder fraud hotline + "idcare.org", # AU/NZ equivalent + "accessnow.org/help", # for people at elevated risk + "lawhelp.org", # legal aid + ): + assert expected in body, f"resources should include {expected}" + + +def test_resources_phase_warns_about_fake_helplines_first(): + """The scam-helpline warning is useless anywhere but the top of the list.""" + resources = _guides()[-1] + first = resources.steps[0] + assert "helpline" in first.title.lower() + assert "gift card" in first.body.lower() + + +def test_recovery_runs_in_the_order_that_makes_it_work(): + titles = {g.phase: g.title for g in _guides()} + # Freezing comes before reporting, reporting before disputing: a dispute + # without an identity theft report gets a form-letter reply. + assert "Freeze" in titles[2] + assert "Report" in titles[3] + assert "Dispute" in titles[4] + + +def test_only_the_device_phase_is_automatable(): + for guide in _guides(): + if guide.phase == 1: + assert guide.automatable_steps + else: + assert guide.automatable_steps == [], ( + f"phase {guide.phase} claims automation the toolkit cannot deliver" + ) + + +def test_every_step_is_classified_exactly_once(): + for guide in _guides(): + classified = set(guide.automatable_steps) | set(guide.human_only_steps) + assert classified == {step.number for step in guide.steps} + assert not set(guide.automatable_steps) & set(guide.human_only_steps) + + +def test_automatable_steps_name_modules_the_profile_includes(): + profile = load_profile(PROFILE_PATH) + available = { + m.name for m in filter_modules_by_profile(discover_modules(MODULES_DIR), profile) + } + + referenced = set() + for guide in _guides(): + for step in guide.steps: + if step.automatable: + referenced |= {name for name in available if name in step.body} + + assert referenced, "automatable steps should name the modules they run" + assert referenced <= available + + +def test_step_titles_stand_alone_as_a_checklist(): + """The CLI prints titles only, so a title has to be actionable by itself.""" + for guide in _guides(): + for step in guide.steps: + assert len(step.title) >= 15 + assert step.body.strip(), f"phase {guide.phase} step {step.number} has no detail" + + +def test_guide_covers_the_reporting_channels_people_miss(): + body = "\n".join(step.body for guide in _guides() for step in guide.steps).lower() + for expected in ( + "identitytheft.gov", # the FTC report that unlocks the legal rights + "police report", + "annualcreditreport.com", # the free source, not a paid lookalike + "identity protection pin", # tax refund fraud + "oig.ssa.gov", # SSN misuse + "optoutprescreen.com", # prescreened offers feed the fraud + ): + assert expected in body, f"guide should mention {expected}" + + +def test_guide_points_outside_the_us(): + body = "\n".join(step.body for guide in _guides() for step in guide.steps).lower() + for expected in ("action fraud", "cifas", "anti-fraud centre", "idcare"): + assert expected in body diff --git a/tests/test_lan_common_neighbors.py b/tests/test_lan_common_neighbors.py new file mode 100644 index 0000000..7b87e46 --- /dev/null +++ b/tests/test_lan_common_neighbors.py @@ -0,0 +1,136 @@ +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.runtime import load_content_module + +NEIGHBORS = load_content_module( + "modules/network/lan_common/neighbors.py", "test_lan_common_neighbors" +) + +DARWIN_ARP = """\ +? (192.168.1.1) at 0:1c:42:0:0:8 on en0 ifscope [ethernet] +? (192.168.1.42) at a4:83:e7:1b:2c:3d on en0 ifscope [ethernet] +? (192.168.1.255) at ff:ff:ff:ff:ff:ff on en0 ifscope [ethernet] +? (224.0.0.251) at 1:0:5e:0:0:fb on en0 ifscope permanent [ethernet] +""" + +LINUX_NEIGH = """\ +192.168.1.1 dev wlan0 lladdr 00:1c:42:00:00:08 REACHABLE +192.168.1.42 dev wlan0 lladdr a4:83:e7:1b:2c:3d STALE +192.168.1.77 dev wlan0 FAILED +""" + +WINDOWS_ARP = """\ + +Interface: 192.168.1.5 --- 0x5 + Internet Address Physical Address Type + 192.168.1.1 00-1C-42-00-00-08 dynamic + 192.168.1.42 A4-83-E7-1B-2C-3D dynamic + 192.168.1.255 ff-ff-ff-ff-ff-ff static +""" + + +def test_normalise_mac_pads_and_lowercases(): + assert NEIGHBORS.normalise_mac("0:1C:42:0:0:8") == "00:1c:42:00:00:08" + assert NEIGHBORS.normalise_mac("A4-83-E7-1B-2C-3D") == "a4:83:e7:1b:2c:3d" + + +def test_normalise_mac_leaves_unparseable_values_alone(): + assert NEIGHBORS.normalise_mac("not-a-mac") == "not-a-mac" + + +def test_darwin_arp_parsing_skips_broadcast_and_multicast(): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=DARWIN_ARP, returncode=0) + neighbors = NEIGHBORS.read_neighbors("darwin") + + assert [n.ip for n in neighbors] == ["192.168.1.1", "192.168.1.42"] + assert neighbors[0].mac == "00:1c:42:00:00:08" + assert neighbors[0].interface == "en0" + + +def test_linux_neigh_parsing_skips_entries_without_a_hardware_address(): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=LINUX_NEIGH, returncode=0) + neighbors = NEIGHBORS.read_neighbors("linux") + + assert [n.ip for n in neighbors] == ["192.168.1.1", "192.168.1.42"] + assert neighbors[1].interface == "wlan0" + + +def test_windows_arp_parsing_records_the_interface_address(): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=WINDOWS_ARP, returncode=0) + neighbors = NEIGHBORS.read_neighbors("win32") + + assert [n.ip for n in neighbors] == ["192.168.1.1", "192.168.1.42"] + assert all(n.interface == "192.168.1.5" for n in neighbors) + + +def test_platform_without_support_returns_nothing(): + assert NEIGHBORS.read_neighbors("plan9") == [] + + +def test_locally_administered_bit_detection(): + vendor_assigned = NEIGHBORS.Neighbor( + ip="192.168.1.1", mac="00:1c:42:00:00:08", interface="en0" + ) + software_generated = NEIGHBORS.Neighbor( + ip="192.168.1.1", mac="02:1c:42:00:00:08", interface="en0" + ) + assert not vendor_assigned.is_locally_administered + assert software_generated.is_locally_administered + + +def test_linux_gateway_parsing(): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + stdout="default via 192.168.1.1 dev wlan0 proto dhcp metric 600\n", + returncode=0, + ) + gateways = NEIGHBORS.read_gateways("linux") + + assert len(gateways) == 1 + assert gateways[0].ip == "192.168.1.1" + assert gateways[0].interface == "wlan0" + + +def test_darwin_gateway_parsing(): + output = " route to: default\n gateway: 192.168.1.1\ninterface: en0\n" + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=output, returncode=0) + gateways = NEIGHBORS.read_gateways("darwin") + + assert gateways[0].ip == "192.168.1.1" + assert gateways[0].interface == "en0" + + +def test_windows_gateway_parsing_deduplicates(): + output = ( + "IPv4 Route Table\n" + "Network Destination Netmask Gateway Interface Metric\n" + " 0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.5 25\n" + " 0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.5 25\n" + " 192.168.1.0 255.255.255.0 On-link 192.168.1.5 281\n" + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=output, returncode=0) + gateways = NEIGHBORS.read_gateways("win32") + + assert [g.ip for g in gateways] == ["192.168.1.1"] + + +def test_command_failure_degrades_to_empty(): + with patch("subprocess.run", side_effect=OSError("no such command")): + assert NEIGHBORS.read_neighbors("linux") == [] + assert NEIGHBORS.read_gateways("linux") == [] + + +def test_vendor_lookup_uses_the_bundled_oui_table(): + vendors = NEIGHBORS.load_oui_vendors() + assert vendors, "the bundled OUI table should not be empty" + assert NEIGHBORS.vendor_for("a4:83:e7:1b:2c:3d", vendors) == "Apple" + assert NEIGHBORS.vendor_for("de:ad:be:ef:00:01", vendors) is None diff --git a/tests/test_module_arp_spoof_check.py b/tests/test_module_arp_spoof_check.py new file mode 100644 index 0000000..153b9fd --- /dev/null +++ b/tests/test_module_arp_spoof_check.py @@ -0,0 +1,158 @@ +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import Mode, Platform, RiskLevel, Severity, SystemProfile +from rescue.registry import discover_modules + +HEALTHY_ARP = """\ +192.168.1.1 dev wlan0 lladdr 00:1c:42:00:00:08 REACHABLE +192.168.1.20 dev wlan0 lladdr a4:83:e7:1b:2c:3d REACHABLE +192.168.1.31 dev wlan0 lladdr de:ad:be:ef:00:01 STALE +""" + +# The attacker's address answering both for the router and for two other hosts. +SPOOFED_ARP = """\ +192.168.1.1 dev wlan0 lladdr de:ad:be:ef:00:01 REACHABLE +192.168.1.20 dev wlan0 lladdr de:ad:be:ef:00:01 REACHABLE +192.168.1.31 dev wlan0 lladdr de:ad:be:ef:00:01 REACHABLE +""" + +# No gateway involved, but one address claiming several hosts. +DUPLICATE_ARP = """\ +192.168.1.1 dev wlan0 lladdr 00:1c:42:00:00:08 REACHABLE +192.168.1.20 dev wlan0 lladdr 52:54:00:12:34:56 REACHABLE +192.168.1.21 dev wlan0 lladdr 52:54:00:12:34:56 REACHABLE +192.168.1.22 dev wlan0 lladdr 52:54:00:12:34:56 REACHABLE +""" + +# The gateway reporting a software-generated address (locally administered bit). +LOCAL_MAC_ARP = "192.168.1.1 dev wlan0 lladdr 02:1c:42:00:00:08 REACHABLE\n" + +ONE_GATEWAY = "default via 192.168.1.1 dev wlan0 proto dhcp metric 600\n" +TWO_GATEWAYS = ( + "default via 192.168.1.1 dev wlan0 proto dhcp metric 600\n" + "default via 192.168.1.99 dev eth0 proto static metric 100\n" +) + + +def _make_profile(): + return SystemProfile( + platform=Platform.LINUX, + os_name="Ubuntu", + os_version="24.04", + architecture="x86_64", + cpu_model="Intel", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "arp_spoof_check") + + +def _fake_commands(arp, route=ONE_GATEWAY): + def run(command, *args, **kwargs): + if command[:2] == ["ip", "neigh"]: + return MagicMock(stdout=arp, returncode=0) + if command[:2] == ["ip", "route"]: + return MagicMock(stdout=route, returncode=0) + return MagicMock(stdout="", returncode=0) + + return run + + +def _check(arp, route=ONE_GATEWAY): + mod = _get_module() + with patch("subprocess.run", side_effect=_fake_commands(arp, route)): + return mod, mod.check(_make_profile()) + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "arp_spoof_check" + assert mod.category == "network" + assert mod.risk_level == RiskLevel.SAFE + + +def test_healthy_network_has_no_findings(): + _, result = _check(HEALTHY_ARP) + assert result.error is None + assert not result.has_issues + + +def test_gateway_impersonation_is_critical(): + _, result = _check(SPOOFED_ARP) + impersonation = [ + f for f in result.findings if f.data["check"] == "gateway_impersonation" + ] + assert len(impersonation) == 1 + assert impersonation[0].severity == Severity.CRITICAL + assert impersonation[0].data["mac"] == "de:ad:be:ef:00:01" + assert sorted(impersonation[0].data["other_ips"]) == ["192.168.1.20", "192.168.1.31"] + + +def test_gateway_impersonation_is_not_double_reported_as_a_duplicate(): + _, result = _check(SPOOFED_ARP) + assert not any(f.data["check"] == "duplicate_mac" for f in result.findings) + + +def test_duplicate_mac_without_the_gateway_is_a_low_confidence_warning(): + _, result = _check(DUPLICATE_ARP) + duplicates = [f for f in result.findings if f.data["check"] == "duplicate_mac"] + assert len(duplicates) == 1 + assert duplicates[0].severity == Severity.WARNING + assert duplicates[0].data["confidence"] == "low" + + +def test_two_addresses_for_one_device_is_below_the_threshold(): + arp = ( + "192.168.1.20 dev wlan0 lladdr 52:54:00:12:34:56 REACHABLE\n" + "192.168.1.21 dev wlan0 lladdr 52:54:00:12:34:56 REACHABLE\n" + ) + _, result = _check(arp) + assert not any(f.data["check"] == "duplicate_mac" for f in result.findings) + + +def test_locally_administered_gateway_address_is_flagged(): + _, result = _check(LOCAL_MAC_ARP) + flagged = [ + f + for f in result.findings + if f.data["check"] == "locally_administered_gateway" + ] + assert len(flagged) == 1 + assert flagged[0].data["mac"] == "02:1c:42:00:00:08" + + +def test_multiple_default_gateways_are_flagged(): + _, result = _check(HEALTHY_ARP, route=TWO_GATEWAYS) + flagged = [f for f in result.findings if f.data["check"] == "multiple_gateways"] + assert len(flagged) == 1 + assert flagged[0].data["gateways"] == ["192.168.1.1", "192.168.1.99"] + + +def test_unreadable_neighbour_table_is_reported_as_unavailable(): + mod = _get_module() + with patch("subprocess.run", side_effect=OSError("command not found")): + result = mod.check(_make_profile()) + assert result.error is not None + assert not result.findings + + +def test_fix_leads_with_containment_and_is_guidance_only(): + mod, result = _check(SPOOFED_ARP) + fix = mod.fix(result, Mode.CLI) + + assert all(action.kind.value == "guidance" for action in fix.actions) + assert "sensitive" in fix.actions[0].title.lower() + + +def test_fix_does_nothing_on_a_healthy_network(): + mod, result = _check(HEALTHY_ARP) + assert mod.fix(result, Mode.CLI).actions == [] diff --git a/tests/test_module_browser_cryptojacking_check.py b/tests/test_module_browser_cryptojacking_check.py new file mode 100644 index 0000000..b4167d9 --- /dev/null +++ b/tests/test_module_browser_cryptojacking_check.py @@ -0,0 +1,199 @@ +import json +import sys +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import Mode, Platform, RiskLevel, Severity, SystemProfile +from rescue.registry import discover_modules + + +def _make_profile(platform=Platform.LINUX): + return SystemProfile( + platform=platform, + os_name="Ubuntu", + os_version="24.04", + architecture="x86_64", + cpu_model="Intel", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "browser_cryptojacking_check") + + +def _namespace(mod): + return sys.modules[type(mod).__module__] + + +def _make_extension(root: Path, extension_id: str, manifest: dict, files: dict): + version_dir = root / "Default" / "Extensions" / extension_id / "1.0_0" + version_dir.mkdir(parents=True) + (version_dir / "manifest.json").write_text(json.dumps(manifest)) + for name, content in files.items(): + (version_dir / name).write_text(content) + return version_dir + + +def _check(mod, chromium_root=None, hosts_file=None, platform=Platform.LINUX): + namespace = _namespace(mod) + chromium = {platform: [str(chromium_root)]} if chromium_root else {platform: []} + hosts = {platform: str(hosts_file)} if hosts_file else {} + with patch.object(namespace, "_CHROMIUM_PROFILE_ROOTS", chromium): + with patch.object(namespace, "_FIREFOX_PROFILE_ROOTS", {platform: []}): + with patch.object(namespace, "_HOSTS_FILES", hosts): + return mod.check(_make_profile(platform)) + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "browser_cryptojacking_check" + assert mod.category == "security" + assert mod.risk_level == RiskLevel.SAFE + assert set(mod.platforms) == {Platform.DARWIN, Platform.WIN32, Platform.LINUX} + + +def test_ordinary_extension_is_not_flagged(tmp_path): + mod = _get_module() + _make_extension( + tmp_path, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + {"name": "Reading List", "version": "1.0"}, + {"background.js": "chrome.storage.local.get('items');"}, + ) + result = _check(mod, chromium_root=tmp_path) + assert not result.has_issues + + +def test_extension_calling_a_mining_service_is_critical(tmp_path): + mod = _get_module() + _make_extension( + tmp_path, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + {"name": "Tab Manager Plus", "version": "2.1"}, + {"background.js": "var s='https://coinhive.com/lib/coinhive.min.js';"}, + ) + + result = _check(mod, chromium_root=tmp_path) + + findings = [f for f in result.findings if f.data["check"] == "mining_extension"] + assert len(findings) == 1 + assert findings[0].severity == Severity.CRITICAL + assert findings[0].data["extension_name"] == "Tab Manager Plus" + assert findings[0].data["evidence"] == "coinhive.com" + + +def test_extension_named_after_a_known_miner_is_flagged(tmp_path): + mod = _get_module() + _make_extension( + tmp_path, + "cccccccccccccccccccccccccccccccc", + {"name": "SafeBrowse", "version": "3.0"}, + {"background.js": "console.log('hello');"}, + ) + + result = _check(mod, chromium_root=tmp_path) + + findings = [f for f in result.findings if f.data["check"] == "mining_extension"] + assert len(findings) == 1 + assert findings[0].data["indicator"] == "known_mining_extension" + + +def test_localised_extension_name_falls_back_to_the_extension_id(tmp_path): + mod = _get_module() + _make_extension( + tmp_path, + "dddddddddddddddddddddddddddddddd", + {"name": "__MSG_appName__", "version": "1.0"}, + {"worker.js": "CoinHive.Anonymous('key').start();"}, + ) + + result = _check(mod, chromium_root=tmp_path) + + assert result.findings[0].data["extension_name"] == ( + "dddddddddddddddddddddddddddddddd" + ) + + +def test_startup_page_pointing_at_a_mining_site_is_flagged(tmp_path): + mod = _get_module() + profile_dir = tmp_path / "Default" + profile_dir.mkdir(parents=True) + (profile_dir / "Preferences").write_text( + json.dumps( + { + "session": {"startup_urls": ["https://webminepool.com/start"]}, + "homepage": "https://example.com", + } + ) + ) + + result = _check(mod, chromium_root=tmp_path) + + findings = [ + f for f in result.findings if f.data["check"] == "mining_startup_page" + ] + assert len(findings) == 1 + assert findings[0].severity == Severity.CRITICAL + + +def test_hosts_file_blocklist_entry_is_not_treated_as_an_attack(tmp_path): + mod = _get_module() + hosts = tmp_path / "hosts" + hosts.write_text("127.0.0.1 localhost\n0.0.0.0 coinhive.com\n") + + result = _check(mod, hosts_file=hosts) + + assert not any( + f.data["check"] == "mining_hosts_entry" for f in result.findings + ) + + +def test_hosts_file_redirecting_a_mining_domain_elsewhere_is_flagged(tmp_path): + mod = _get_module() + hosts = tmp_path / "hosts" + hosts.write_text("127.0.0.1 localhost\n45.9.148.99 pool.minexmr.com # keepalive\n") + + result = _check(mod, hosts_file=hosts) + + findings = [ + f for f in result.findings if f.data["check"] == "mining_hosts_entry" + ] + assert len(findings) == 1 + assert findings[0].data["address"] == "45.9.148.99" + assert findings[0].data["hostname"] == "pool.minexmr.com" + + +def test_fix_is_guidance_only_and_names_the_extension(tmp_path): + mod = _get_module() + _make_extension( + tmp_path, + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + {"name": "Tab Manager Plus", "version": "2.1"}, + {"background.js": "CryptoLoot.Anonymous('key');"}, + ) + result = _check(mod, chromium_root=tmp_path) + + fix = mod.fix(result, Mode.CLI) + + assert all(action.kind.value == "guidance" for action in fix.actions) + assert any("Tab Manager Plus" in action.title for action in fix.actions) + assert any("content blocker" in action.title for action in fix.actions) + + +def test_fix_does_nothing_without_findings(tmp_path): + mod = _get_module() + result = _check(mod, chromium_root=tmp_path) + assert mod.fix(result, Mode.CLI).actions == [] + + +def test_missing_browser_directories_do_not_raise(): + mod = _get_module() + result = _check(mod, chromium_root=Path("/does/not/exist")) + assert result.error is None + assert not result.has_issues diff --git a/tests/test_module_crypto_miner_persistence.py b/tests/test_module_crypto_miner_persistence.py new file mode 100644 index 0000000..0bc38fc --- /dev/null +++ b/tests/test_module_crypto_miner_persistence.py @@ -0,0 +1,243 @@ +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import Mode, Platform, RiskLevel, Severity, SystemProfile +from rescue.registry import discover_modules + +# A Monero address's shape: 95 base58 characters beginning with 4 or 8. Not a +# real wallet — the point is that the pattern is what gets recognised. +WALLET = ( + "4123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabc" +) + +LAUNCH_AGENT = """\ + + + + Labelcom.apple.softwareupdate.helper + ProgramArguments + + /tmp/.x/kworker + -o + stratum+tcp://pool.supportxmr.com:3333 + + RunAtLoad + + +""" + +BENIGN_LAUNCH_AGENT = """\ + + + + Labelcom.example.backup + ProgramArguments/usr/local/bin/backup + + +""" + + +def _make_profile(platform=Platform.DARWIN): + return SystemProfile( + platform=platform, + os_name="macOS", + os_version="15.2", + architecture="arm64", + cpu_model="Apple M2", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "crypto_miner_persistence") + + +def _namespace(mod): + return sys.modules[type(mod).__module__] + + +def _no_commands(*args, **kwargs): + return MagicMock(stdout="", returncode=0) + + +def _check_darwin(mod, persistence_dir=None, drop_dir=None, profiles=()): + namespace = _namespace(mod) + with patch("subprocess.run", side_effect=_no_commands): + with patch.object( + namespace, + "_DARWIN_PERSISTENCE_DIRS", + [str(persistence_dir)] if persistence_dir else [], + ): + with patch.object( + namespace, + "_DARWIN_DROP_DIRS", + [str(drop_dir)] if drop_dir else [], + ): + with patch.object(namespace, "_SHELL_PROFILES", list(profiles)): + return mod.check(_make_profile()) + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "crypto_miner_persistence" + assert mod.category == "security" + assert mod.risk_level == RiskLevel.SAFE + assert set(mod.platforms) == {Platform.DARWIN, Platform.WIN32, Platform.LINUX} + + +def test_clean_system_has_no_findings(tmp_path): + mod = _get_module() + (tmp_path / "com.example.backup.plist").write_text(BENIGN_LAUNCH_AGENT) + result = _check_darwin(mod, persistence_dir=tmp_path) + assert not result.has_issues + + +def test_launch_agent_running_a_miner_is_critical(tmp_path): + mod = _get_module() + (tmp_path / "com.apple.softwareupdate.helper.plist").write_text(LAUNCH_AGENT) + + result = _check_darwin(mod, persistence_dir=tmp_path) + + findings = [f for f in result.findings if f.data["check"] == "miner_launch_item"] + assert len(findings) == 1 + assert findings[0].severity == Severity.CRITICAL + assert findings[0].data["indicator"] == "miner_arguments" + assert findings[0].data["confidence"] == "high" + + +def test_wallet_address_outranks_every_other_indicator(tmp_path): + mod = _get_module() + (tmp_path / "com.user.helper.plist").write_text( + f"--user {WALLET}" + ) + + result = _check_darwin(mod, persistence_dir=tmp_path) + + assert result.findings[0].data["indicator"] == "monero_wallet_address" + assert result.findings[0].severity == Severity.CRITICAL + # The address itself is kept as evidence, but truncated in the prose. + assert result.findings[0].data["evidence"] == WALLET + assert WALLET not in result.findings[0].description + + +def test_known_pool_domain_is_detected(tmp_path): + mod = _get_module() + (tmp_path / "com.user.helper.plist").write_text( + "https://moneroocean.stream/setup.sh" + ) + + result = _check_darwin(mod, persistence_dir=tmp_path) + + assert result.findings[0].data["indicator"] == "mining_pool" + assert result.findings[0].data["evidence"] == "moneroocean.stream" + + +def test_shell_profile_starting_a_miner_is_detected(tmp_path): + mod = _get_module() + profile_file = tmp_path / ".zshrc" + profile_file.write_text("export PATH=$PATH:/usr/local/bin\nnohup xmrig &\n") + + result = _check_darwin(mod, profiles=[str(profile_file)]) + + findings = [f for f in result.findings if f.data["check"] == "miner_shell_profile"] + assert len(findings) == 1 + assert findings[0].data["indicator"] == "known_miner" + + +def test_dropped_config_needs_strong_evidence_not_just_a_product_name(tmp_path): + mod = _get_module() + # A name-only mention in an arbitrary JSON file is not enough... + (tmp_path / "settings.json").write_text('{"note": "compare against nicehash"}') + assert not _check_darwin(mod, drop_dir=tmp_path).has_issues + + # ...but a pool URL in the same place is. + (tmp_path / "config.json").write_text( + '{"pools": [{"url": "stratum+tcp://pool.minexmr.com:4444"}]}' + ) + result = _check_darwin(mod, drop_dir=tmp_path) + findings = [f for f in result.findings if f.data["check"] == "miner_config_file"] + assert len(findings) == 1 + + +def test_cron_job_starting_a_miner_is_detected(): + mod = _get_module() + namespace = _namespace(mod) + + def run(command, *args, **kwargs): + if command[:2] == ["crontab", "-l"]: + return MagicMock( + stdout="# comment\n*/5 * * * * /tmp/.x --donate-level 1\n", + returncode=0, + ) + return MagicMock(stdout="", returncode=0) + + with patch("subprocess.run", side_effect=run): + with patch.object(namespace, "_DARWIN_PERSISTENCE_DIRS", []): + with patch.object(namespace, "_DARWIN_DROP_DIRS", []): + with patch.object(namespace, "_SHELL_PROFILES", []): + result = mod.check(_make_profile()) + + findings = [f for f in result.findings if f.data["check"] == "miner_cron_job"] + assert len(findings) == 1 + assert findings[0].severity == Severity.CRITICAL + + +def test_windows_run_key_pointing_at_a_pool_is_detected(): + mod = _get_module() + + def run(command, *args, **kwargs): + if command[:2] == ["reg", "query"]: + return MagicMock( + stdout=( + "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\n" + " OneDriveUpdate REG_SZ " + "C:\\Users\\a\\AppData\\svc.exe -o stratum+tcp://c3pool.com:80\n" + ), + returncode=0, + ) + return MagicMock(stdout="", returncode=0) + + with patch("subprocess.run", side_effect=run): + result = mod.check(_make_profile(Platform.WIN32)) + + findings = [f for f in result.findings if f.data["check"] == "miner_run_key"] + assert findings + assert findings[0].severity == Severity.CRITICAL + + +def test_fix_is_guidance_only_and_explains_the_removal_order(tmp_path): + mod = _get_module() + (tmp_path / "com.apple.softwareupdate.helper.plist").write_text(LAUNCH_AGENT) + result = _check_darwin(mod, persistence_dir=tmp_path) + + fix = mod.fix(result, Mode.CLI) + + assert all(action.kind.value == "guidance" for action in fix.actions) + assert all(not action.executed for action in fix.actions) + assert any("how the miner got installed" in a.title for a in fix.actions) + + +def test_fix_does_nothing_without_findings(tmp_path): + mod = _get_module() + result = _check_darwin(mod, persistence_dir=tmp_path) + assert mod.fix(result, Mode.CLI).actions == [] + + +def test_unreadable_locations_do_not_raise(tmp_path): + mod = _get_module() + namespace = _namespace(mod) + with patch("subprocess.run", side_effect=OSError("no crontab")): + with patch.object( + namespace, "_DARWIN_PERSISTENCE_DIRS", [str(tmp_path / "missing")] + ): + with patch.object(namespace, "_DARWIN_DROP_DIRS", ["/does/not/exist"]): + result = mod.check(_make_profile()) + assert result.error is None + assert not result.has_issues diff --git a/tests/test_module_lan_device_inventory.py b/tests/test_module_lan_device_inventory.py new file mode 100644 index 0000000..d3199fd --- /dev/null +++ b/tests/test_module_lan_device_inventory.py @@ -0,0 +1,138 @@ +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import Mode, Platform, RiskLevel, Severity, SystemProfile +from rescue.registry import discover_modules + +ARP_OUTPUT = """\ +192.168.1.1 dev wlan0 lladdr 00:1c:42:00:00:08 REACHABLE +192.168.1.20 dev wlan0 lladdr a4:83:e7:1b:2c:3d REACHABLE +192.168.1.31 dev wlan0 lladdr de:ad:be:ef:00:01 STALE +""" + +ROUTE_OUTPUT = "default via 192.168.1.1 dev wlan0 proto dhcp metric 600\n" + + +def _make_profile(platform=Platform.LINUX): + return SystemProfile( + platform=platform, + os_name="Ubuntu", + os_version="24.04", + architecture="x86_64", + cpu_model="Intel", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "lan_device_inventory") + + +def _fake_commands(arp=ARP_OUTPUT, route=ROUTE_OUTPUT): + def run(command, *args, **kwargs): + if command[:2] == ["ip", "neigh"]: + return MagicMock(stdout=arp, returncode=0) + if command[:2] == ["ip", "route"]: + return MagicMock(stdout=route, returncode=0) + return MagicMock(stdout="", returncode=0) + + return run + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "lan_device_inventory" + assert mod.category == "network" + assert mod.risk_level == RiskLevel.SAFE + assert set(mod.platforms) == {Platform.DARWIN, Platform.WIN32, Platform.LINUX} + + +def test_inventory_lists_every_device(): + mod = _get_module() + with patch("subprocess.run", side_effect=_fake_commands()): + result = mod.check(_make_profile()) + + assert result.error is None + inventory = next( + f for f in result.findings if f.data["check"] == "lan_inventory" + ) + assert inventory.severity == Severity.INFO + assert inventory.data["device_count"] == 3 + macs = {device["mac"] for device in inventory.data["devices"]} + assert macs == {"00:1c:42:00:00:08", "a4:83:e7:1b:2c:3d", "de:ad:be:ef:00:01"} + + +def test_inventory_labels_the_gateway_and_known_vendors(): + mod = _get_module() + with patch("subprocess.run", side_effect=_fake_commands()): + result = mod.check(_make_profile()) + + devices = { + d["mac"]: d + for d in result.findings[0].data["devices"] + } + assert devices["00:1c:42:00:00:08"]["is_gateway"] is True + assert devices["a4:83:e7:1b:2c:3d"]["vendor"] == "Apple" + assert devices["de:ad:be:ef:00:01"]["vendor"] is None + + +def test_unreadable_neighbour_table_is_reported_as_unavailable_not_healthy(): + mod = _get_module() + with patch("subprocess.run", side_effect=OSError("command not found")): + result = mod.check(_make_profile()) + + assert result.error is not None + assert not result.findings + + +def test_known_macs_configuration_flags_only_the_rest(): + mod = _get_module() + mod.configure({"known_macs": ["00:1C:42:0:0:8", "a4:83:e7:1b:2c:3d"]}) + + with patch("subprocess.run", side_effect=_fake_commands()): + result = mod.check(_make_profile()) + + unrecognised = [ + f for f in result.findings if f.data["check"] == "unrecognised_device" + ] + assert len(unrecognised) == 1 + assert unrecognised[0].data["mac"] == "de:ad:be:ef:00:01" + assert unrecognised[0].severity == Severity.WARNING + + +def test_expected_device_count_warns_only_when_exceeded(): + mod = _get_module() + mod.configure({"expected_device_count": 2}) + with patch("subprocess.run", side_effect=_fake_commands()): + exceeded = mod.check(_make_profile()) + assert any(f.data["check"] == "device_count_exceeded" for f in exceeded.findings) + + mod = _get_module() + mod.configure({"expected_device_count": 5}) + with patch("subprocess.run", side_effect=_fake_commands()): + within = mod.check(_make_profile()) + assert not any(f.data["check"] == "device_count_exceeded" for f in within.findings) + + +def test_fix_is_guidance_only(): + mod = _get_module() + with patch("subprocess.run", side_effect=_fake_commands()): + result = mod.check(_make_profile()) + fix = mod.fix(result, Mode.CLI) + + assert fix.actions + assert all(action.kind.value == "guidance" for action in fix.actions) + assert all(not action.executed for action in fix.actions) + + +def test_fix_does_nothing_without_findings(): + mod = _get_module() + with patch("subprocess.run", side_effect=OSError("command not found")): + result = mod.check(_make_profile()) + assert mod.fix(result, Mode.CLI).actions == [] diff --git a/tests/test_module_router_security_audit.py b/tests/test_module_router_security_audit.py new file mode 100644 index 0000000..befc909 --- /dev/null +++ b/tests/test_module_router_security_audit.py @@ -0,0 +1,118 @@ +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import Mode, Platform, RiskLevel, Severity, SystemProfile +from rescue.registry import discover_modules + +ROUTE_OUTPUT = "default via 192.168.1.1 dev wlan0 proto dhcp metric 600\n" + + +def _make_profile(): + return SystemProfile( + platform=Platform.LINUX, + os_name="Ubuntu", + os_version="24.04", + architecture="x86_64", + cpu_model="Intel", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "router_security_audit") + + +def _module_namespace(mod): + """The loaded module object, so module-level helpers can be patched.""" + return sys.modules[type(mod).__module__] + + +def _route_command(*args, **kwargs): + command = args[0] if args else kwargs.get("args", []) + if command[:2] == ["ip", "route"]: + return MagicMock(stdout=ROUTE_OUTPUT, returncode=0) + return MagicMock(stdout="", returncode=0) + + +def _check(open_ports=(), upnp=""): + mod = _get_module() + namespace = _module_namespace(mod) + with patch("subprocess.run", side_effect=_route_command): + with patch.object( + namespace, "_tcp_open", side_effect=lambda host, port: port in open_ports + ): + with patch.object(namespace, "_ssdp_discover", return_value=upnp): + return mod, mod.check(_make_profile()) + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "router_security_audit" + assert mod.category == "network" + assert mod.risk_level == RiskLevel.SAFE + + +def test_no_gateway_is_reported_as_unavailable_not_healthy(): + mod = _get_module() + with patch("subprocess.run", return_value=MagicMock(stdout="", returncode=0)): + result = mod.check(_make_profile()) + assert result.error is not None + assert not result.findings + + +def test_closed_router_reports_only_the_manual_review_reminder(): + _, result = _check(open_ports=()) + assert [f.data["check"] for f in result.findings] == ["router_manual_review"] + assert result.findings[0].severity == Severity.INFO + assert result.findings[0].data["gateway_ip"] == "192.168.1.1" + + +def test_telnet_on_the_router_is_critical(): + _, result = _check(open_ports=(23,)) + telnet = next( + f for f in result.findings if f.data.get("port") == 23 + ) + assert telnet.severity == Severity.CRITICAL + assert telnet.data["check"] == "admin_service_open" + + +def test_https_admin_page_is_informational_not_a_problem(): + _, result = _check(open_ports=(443,)) + https = next(f for f in result.findings if f.data.get("port") == 443) + assert https.severity == Severity.INFO + + +def test_tr069_management_port_is_flagged(): + _, result = _check(open_ports=(7547,)) + assert any(f.data.get("port") == 7547 for f in result.findings) + + +def test_upnp_response_is_flagged(): + _, result = _check(upnp="HTTP/1.1 200 OK\r\nST: InternetGatewayDevice\r\n") + assert any(f.data["check"] == "upnp_enabled" for f in result.findings) + + +def test_silent_upnp_is_not_flagged(): + _, result = _check(upnp="") + assert not any(f.data["check"] == "upnp_enabled" for f in result.findings) + + +def test_fix_is_guidance_only_and_always_offers_the_reclaim_procedure(): + mod, result = _check(open_ports=(23, 80)) + fix = mod.fix(result, Mode.CLI) + + assert all(action.kind.value == "guidance" for action in fix.actions) + assert all(not action.executed for action in fix.actions) + assert any("reclaim" in action.title.lower() for action in fix.actions) + + +def test_fix_does_not_ask_to_disable_the_expected_admin_page(): + mod, result = _check(open_ports=(443,)) + fix = mod.fix(result, Mode.CLI) + assert not any(action.data.get("port") == 443 for action in fix.actions) diff --git a/tests/test_module_stalkerware_scan.py b/tests/test_module_stalkerware_scan.py new file mode 100644 index 0000000..d47ae59 --- /dev/null +++ b/tests/test_module_stalkerware_scan.py @@ -0,0 +1,191 @@ +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import ( + Mode, + Platform, + ProcessInfo, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.registry import discover_modules + +DATA_FILE = ( + Path(__file__).parent.parent + / "modules" / "security" / "stalkerware_scan" / "data" / "known_stalkerware.json" +) + + +def _make_profile(processes=(), installed=(), platform=Platform.DARWIN): + return SystemProfile( + platform=platform, + os_name="macOS", + os_version="15.2", + architecture="arm64", + cpu_model="Apple M2", + cpu_cores=8, + ram_bytes=16 * 1024**3, + processes=list(processes), + installed_software=list(installed), + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "stalkerware_scan") + + +def _namespace(mod): + return sys.modules[type(mod).__module__] + + +def _process(name, command=""): + return ProcessInfo( + pid=42, name=name, cpu_percent=1.0, memory_bytes=1024, command=command or name + ) + + +def _check(mod, profile): + """Run check with the filesystem and registry scans stubbed out.""" + namespace = _namespace(mod) + with patch("subprocess.run", return_value=MagicMock(stdout="", returncode=0)): + with patch.object(namespace, "_DARWIN_APP_DIRS", []): + with patch.object(namespace, "_DARWIN_PERSISTENCE_DIRS", []): + return mod.check(profile) + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "stalkerware_scan" + assert mod.category == "security" + assert mod.risk_level == RiskLevel.SAFE + assert set(mod.platforms) == {Platform.DARWIN, Platform.WIN32, Platform.LINUX} + + +def test_data_file_is_valid_and_categorised(): + with open(DATA_FILE) as f: + data = json.load(f) + entries = data["entries"] + assert len(entries) >= 20 + valid_categories = { + "stalkerware", + "employee_monitoring", + "parental_control", + "remote_access", + } + for entry in entries: + assert entry["category"] in valid_categories + assert entry["severity"] in {"critical", "warning", "info"} + assert entry["platforms"] + assert entry["description"] + + +def test_clean_machine_has_no_findings(): + mod = _get_module() + result = _check(mod, _make_profile(processes=[_process("Finder")])) + assert not result.has_issues + + +def test_stalkerware_process_is_critical(): + mod = _get_module() + result = _check( + mod, + _make_profile(processes=[_process("mSpy Agent", "/Applications/mSpy.app/agent")]), + ) + + findings = result.findings + assert len(findings) == 1 + assert findings[0].severity == Severity.CRITICAL + assert findings[0].data["stalkerware_category"] == "stalkerware" + assert findings[0].data["confidence"] == "high" + + +def test_remote_access_tool_is_reported_without_calling_it_malware(): + mod = _get_module() + result = _check(mod, _make_profile(installed=["TeamViewer Host"])) + + assert len(result.findings) == 1 + assert result.findings[0].severity == Severity.INFO + assert result.findings[0].data["stalkerware_category"] == "remote_access" + + +def test_short_product_names_do_not_match_inside_longer_words(): + mod = _get_module() + # "bark" is a parental-control product; "barkeeper" is not it. + result = _check(mod, _make_profile(processes=[_process("barkeeper")])) + assert not result.has_issues + + +def test_platform_specific_entries_are_skipped_on_other_platforms(): + mod = _get_module() + # Spyrix is a Windows-only product in the dataset. + result = _check( + mod, _make_profile(processes=[_process("spyrix")], platform=Platform.DARWIN) + ) + assert not result.has_issues + + result = _check( + mod, _make_profile(processes=[_process("spyrix")], platform=Platform.LINUX) + ) + assert not result.has_issues + + +def test_the_same_product_found_twice_is_reported_once_per_location(): + mod = _get_module() + result = _check( + mod, + _make_profile( + processes=[_process("mSpy Agent", "/Applications/mSpy.app/agent")], + installed=["mSpy"], + ), + ) + locations = {f.data["location"] for f in result.findings} + assert len(result.findings) == len(locations) == 2 + + +def test_fix_leads_with_safety_guidance_before_removal(): + mod = _get_module() + result = _check( + mod, + _make_profile(processes=[_process("mSpy Agent", "/Applications/mSpy.app/agent")]), + ) + + fix = mod.fix(result, Mode.CLI) + titles = [action.title for action in fix.actions] + + assert titles[0] == "Read this before removing the monitoring software" + assert "1-800-799-7233" in fix.actions[0].description + removal_index = next(i for i, t in enumerate(titles) if t.startswith("Remove mSpy")) + assert removal_index > 0 + assert all(action.kind.value == "guidance" for action in fix.actions) + + +def test_remote_access_only_findings_skip_the_stalkerware_safety_preamble(): + mod = _get_module() + result = _check(mod, _make_profile(installed=["AnyDesk"])) + + fix = mod.fix(result, Mode.CLI) + titles = [action.title for action in fix.actions] + + assert titles == ["Account for the remote access tool AnyDesk"] + + +def test_fix_does_nothing_without_findings(): + mod = _get_module() + result = _check(mod, _make_profile()) + assert mod.fix(result, Mode.CLI).actions == [] + + +def test_missing_data_file_is_reported_as_unavailable_not_healthy(): + mod = _get_module() + namespace = _namespace(mod) + with patch.object(namespace, "DATA_FILE", Path("/does/not/exist.json")): + result = mod.check(_make_profile()) + assert result.error is not None + assert not result.findings diff --git a/tests/test_module_win_crypto_miner_detect.py b/tests/test_module_win_crypto_miner_detect.py new file mode 100644 index 0000000..dd4a178 --- /dev/null +++ b/tests/test_module_win_crypto_miner_detect.py @@ -0,0 +1,152 @@ +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import Mode, Platform, RiskLevel, Severity, SystemProfile +from rescue.registry import discover_modules + +WALLET = ( + "4123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabc" +) + +CLEAN_PROCESSES = ( + '"ProcessId","Name","CommandLine"\n' + '"4","System",""\n' + '"1200","chrome.exe","C:\\Program Files\\Google\\Chrome\\chrome.exe"\n' +) + +MINER_PROCESSES = ( + '"ProcessId","Name","CommandLine"\n' + '"1200","chrome.exe","C:\\Program Files\\Google\\Chrome\\chrome.exe"\n' + '"4310","svchost.exe","C:\\Users\\a\\AppData\\svchost.exe ' + f'-o stratum+tcp://pool.supportxmr.com:3333 -u {WALLET}"\n' +) + +BUSY_COUNTERS = ( + '"Name","IDProcess","PercentProcessorTime"\n' + '"chrome","1200","95"\n' + '"svchost","900","88"\n' + '"winlogen","4310","91"\n' +) + + +def _make_profile(): + return SystemProfile( + platform=Platform.WIN32, + os_name="Windows 11", + os_version="10.0.26100", + architecture="AMD64", + cpu_model="Intel", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "win_crypto_miner_detect") + + +def _fake_commands(processes=CLEAN_PROCESSES, counters="", netstat=""): + def run(command, *args, **kwargs): + if command[0] == "powershell": + script = command[-1] + if "Win32_Process" in script: + return MagicMock(stdout=processes, returncode=0) + if "PerfFormattedData" in script: + return MagicMock(stdout=counters, returncode=0) + if command[0] == "netstat": + return MagicMock(stdout=netstat, returncode=0) + return MagicMock(stdout="", returncode=0) + + return run + + +def _check(**kwargs): + mod = _get_module() + with patch("subprocess.run", side_effect=_fake_commands(**kwargs)): + return mod, mod.check(_make_profile()) + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "win_crypto_miner_detect" + assert mod.platforms == [Platform.WIN32] + assert mod.risk_level == RiskLevel.SAFE + + +def test_clean_machine_has_no_findings(): + _, result = _check() + assert not result.has_issues + + +def test_miner_command_line_is_critical_and_high_confidence(): + _, result = _check(processes=MINER_PROCESSES) + findings = [f for f in result.findings if f.data["check"] == "known_miner"] + assert len(findings) == 1 + assert findings[0].severity == Severity.CRITICAL + assert findings[0].data["confidence"] == "high" + assert findings[0].data["pid"] == 4310 + + +def test_wallet_address_is_kept_as_evidence_but_not_printed_in_full(): + _, result = _check(processes=MINER_PROCESSES) + finding = result.findings[0] + assert finding.data["indicator"] == "monero_wallet_address" + assert finding.data["evidence"] == WALLET + assert WALLET not in finding.description + + +def test_established_connection_to_a_pool_port_is_flagged(): + netstat = ( + " Proto Local Address Foreign Address State PID\n" + " TCP 192.168.1.5:51000 51.15.65.4:3333 ESTABLISHED 4310\n" + " TCP 192.168.1.5:51001 142.250.0.1:443 ESTABLISHED 1200\n" + ) + _, result = _check(processes=MINER_PROCESSES, netstat=netstat) + pool = [f for f in result.findings if f.data["check"] == "mining_pool_connection"] + assert len(pool) == 1 + assert pool[0].data["port"] == 3333 + assert pool[0].data["process"] == "svchost.exe" + + +def test_listening_sockets_are_not_treated_as_pool_connections(): + netstat = ( + " TCP 0.0.0.0:3333 0.0.0.0:0 LISTENING 900\n" + ) + _, result = _check(netstat=netstat) + assert not any( + f.data["check"] == "mining_pool_connection" for f in result.findings + ) + + +def test_high_cpu_is_a_low_confidence_warning_and_skips_known_processes(): + _, result = _check(counters=BUSY_COUNTERS) + high_cpu = [f for f in result.findings if f.data["check"] == "high_cpu_process"] + # chrome and svchost are expected to spike; the unrecognised one is not. + assert [f.data["process"] for f in high_cpu] == ["winlogen"] + assert high_cpu[0].severity == Severity.WARNING + assert high_cpu[0].data["confidence"] == "low" + + +def test_fix_separates_confirmed_miners_from_high_cpu_leads(): + _, result = _check(processes=MINER_PROCESSES, counters=BUSY_COUNTERS) + mod = _get_module() + fix = mod.fix(result, Mode.CLI) + + titles = [action.title for action in fix.actions] + assert any(title.startswith("Stop and remove the miner") for title in titles) + assert any(title.startswith("Identify what") for title in titles) + assert all(action.kind.value == "guidance" for action in fix.actions) + + +def test_command_failures_degrade_to_no_findings(): + mod = _get_module() + with patch("subprocess.run", side_effect=OSError("powershell missing")): + result = mod.check(_make_profile()) + assert result.error is None + assert not result.has_issues diff --git a/tests/test_module_win_scheduled_tasks_security.py b/tests/test_module_win_scheduled_tasks_security.py index cf4e4fe..687f78f 100644 --- a/tests/test_module_win_scheduled_tasks_security.py +++ b/tests/test_module_win_scheduled_tasks_security.py @@ -53,26 +53,31 @@ def _make_csv_output( rows = [headers] + # Field values are pulled out before formatting: quoting and backslashes + # inside an f-string expression are only legal from Python 3.12, and this + # package supports 3.11. if tasks: for task in tasks: - row = ( - f"{task.get('HostName', 'DESKTOP')}," - f'"{task.get("TaskName", "Task")}","' - f'{task.get("Next Run Time", "")}",' - f'"{task.get("Status", "Ready")}","' - f'{task.get("LogonMode", "Interactive only")}","' - f'{task.get("ScheduleType", "")}",' - f'"{task.get("LastRunTime", "")}",' - f"{task.get("LastResult", "0")}," - f'"{task.get("Author", "")}",' - f'"{task.get("TaskPath", "\\\\")}",' - f'"{task.get("RunAsUser", "")}",' - f'"{task.get("DeletedWhen", "")}",' - f'"{task.get("DeletedFrom", "")}",' - f'"{task.get("Attributes", "")}",' - f'"{task.get("Task To Run", "")}",' - f'"{task.get("Created", "")}"' - ) + values = [ + task.get("HostName", "DESKTOP"), + task.get("TaskName", "Task"), + task.get("Next Run Time", ""), + task.get("Status", "Ready"), + task.get("LogonMode", "Interactive only"), + task.get("ScheduleType", ""), + task.get("LastRunTime", ""), + task.get("LastResult", "0"), + task.get("Author", ""), + task.get("TaskPath", "\\"), + task.get("RunAsUser", ""), + task.get("DeletedWhen", ""), + task.get("DeletedFrom", ""), + task.get("Attributes", ""), + task.get("Task To Run", ""), + task.get("Created", ""), + ] + host_name, rest = values[0], values[1:] + row = f"{host_name}," + ",".join(f'"{value}"' for value in rest) rows.append(row) return "\n".join(rows) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index c5d9d27..03dca26 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,3 +1,5 @@ +import sys + from rescue import runtime @@ -31,3 +33,57 @@ def test_content_file_uses_bundled_content_without_applied_marker(monkeypatch, t monkeypatch.setattr(runtime, "bundled_root", lambda: bundled_root) assert runtime.content_file("guides/phase.md") == bundled_file + + +def test_load_content_module_imports_a_helper_by_path(monkeypatch, tmp_path): + bundled_root = tmp_path / "bundled" + helper = bundled_root / "modules" / "helper.py" + helper.parent.mkdir(parents=True) + helper.write_text("VALUE = 7\n\n\ndef double(n):\n return n * 2\n") + + monkeypatch.setattr(runtime, "bundled_root", lambda: bundled_root) + + module = runtime.load_content_module("modules/helper.py", "test_helper_module") + try: + assert module is not None + assert module.VALUE == 7 + assert module.double(4) == 8 + finally: + sys.modules.pop("test_helper_module", None) + + +def test_load_content_module_reuses_an_already_loaded_helper(monkeypatch, tmp_path): + bundled_root = tmp_path / "bundled" + helper = bundled_root / "modules" / "helper.py" + helper.parent.mkdir(parents=True) + helper.write_text("VALUE = 1\n") + + monkeypatch.setattr(runtime, "bundled_root", lambda: bundled_root) + + first = runtime.load_content_module("modules/helper.py", "test_helper_cached") + try: + helper.write_text("VALUE = 2\n") + second = runtime.load_content_module("modules/helper.py", "test_helper_cached") + assert second is first + assert second.VALUE == 1 + finally: + sys.modules.pop("test_helper_cached", None) + + +def test_load_content_module_returns_none_for_a_missing_file(monkeypatch, tmp_path): + monkeypatch.setattr(runtime, "bundled_root", lambda: tmp_path) + assert runtime.load_content_module("modules/absent.py", "test_helper_absent") is None + + +def test_load_content_module_does_not_leave_a_broken_helper_registered( + monkeypatch, tmp_path +): + bundled_root = tmp_path / "bundled" + helper = bundled_root / "modules" / "broken.py" + helper.parent.mkdir(parents=True) + helper.write_text("raise RuntimeError('boom')\n") + + monkeypatch.setattr(runtime, "bundled_root", lambda: bundled_root) + + assert runtime.load_content_module("modules/broken.py", "test_helper_broken") is None + assert "test_helper_broken" not in sys.modules