From 4afa3698e83dcf6ab2da2ffadc79d4ab730fe01c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 01:47:19 +0000 Subject: [PATCH 1/3] feat: add cryptojacking, home-network intrusion, and stalkerware coverage Adds the detection and remediation content for a household whose Wi-Fi has been broken into, and for the cryptojacking that tends to arrive with it. New modules: - crypto_miner_persistence (macOS/Windows/Linux) finds what restarts a miner after it is killed: launch items, cron, systemd units, Run keys, scheduled tasks, shell profiles, and dropped miner configs. Wallet addresses and stratum arguments are treated as high-confidence evidence; a product name alone is not. - win_crypto_miner_detect gives Windows the live-miner detection that previously existed only for macOS: process command lines, established connections to mining-pool ports, and high CPU as a low-confidence lead. - browser_cryptojacking_check covers in-browser mining, which never shows up in a process list: extension code, startup pages, and hosts-file entries. Blackholed mining domains are recognised as protection, not as a threat. - lan_device_inventory lists the devices actually visible on the network, with OUI vendor labels, and says plainly that a quiet device can be missing. - arp_spoof_check looks for traffic interception: the gateway's address answering for other hosts, duplicate addresses, and multiple default routes. - router_security_audit checks the gateway's exposed admin surface and UPnP, and carries the full router reclaim procedure. - stalkerware_scan separates covert monitoring software from workplace and parental products and from dual-use remote access, and leads its guidance with safety planning rather than removal instructions. Supporting content: shared cryptojacking IOC data, a shared neighbour-table helper, expanded known-bloatware datasets (adware, scareware, bundled miners, OEM trials), and the home_network_intrusion profile and five-phase guide, ordered so the network is reclaimed before the devices are cleaned. rescue.runtime gains load_content_module() so modules can share a helper by path, which works in a source checkout, a pip install, and a frozen bundle. Every fix() here is guidance only, marked ActionKind.GUIDANCE, and every external command and filesystem scan is bounded by a timeout or a cap. Also fixes three pre-existing Python 3.12-only f-strings that made win_malware_indicators and win_bsod_analysis fail to load, and test_module_win_scheduled_tasks_security fail to collect, on the declared minimum Python 3.11. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AsDpjoDi59AXxhoy8TriUQ --- guides/home_network_intrusion/phase_0.md | 54 ++ guides/home_network_intrusion/phase_1.md | 62 ++ guides/home_network_intrusion/phase_2.md | 88 +++ guides/home_network_intrusion/phase_3.md | 92 +++ guides/home_network_intrusion/phase_4.md | 69 +++ .../login_items/data/known_bloatware.json | 40 ++ modules/bloatware/process_scanner/__init__.py | 6 +- .../process_scanner/data/known_bloatware.json | 33 +- .../startup_auditor/data/known_bloatware.json | 55 ++ .../win_bloatware/data/known_bloatware.json | 77 +++ .../integrity/win_bsod_analysis/__init__.py | 10 +- modules/network/arp_spoof_check/__init__.py | 318 ++++++++++ modules/network/lan_common/__init__.py | 5 + modules/network/lan_common/neighbors.py | 253 ++++++++ modules/network/lan_common/oui_vendors.json | 132 +++++ .../network/lan_device_inventory/__init__.py | 284 +++++++++ .../network/router_security_audit/__init__.py | 360 ++++++++++++ .../browser_cryptojacking_check/__init__.py | 549 ++++++++++++++++++ .../crypto_miner_persistence/__init__.py | 532 +++++++++++++++++ .../security/cryptojacking_iocs/__init__.py | 6 + .../cryptojacking_iocs/browser_miners.json | 50 ++ .../cryptojacking_iocs/known_miners.json | 181 ++++++ .../cryptojacking_iocs/known_pools.json | 22 + modules/security/cryptojacking_iocs/loader.py | 147 +++++ .../security/cryptojacking_iocs/manifest.json | 6 + modules/security/stalkerware_scan/__init__.py | 407 +++++++++++++ .../data/known_stalkerware.json | 391 +++++++++++++ .../win_crypto_miner_detect/__init__.py | 424 ++++++++++++++ .../win_malware_indicators/__init__.py | 9 +- profiles/home_network_intrusion.yaml | 40 ++ rescue/runtime.py | 36 ++ tests/test_cryptojacking_iocs.py | 67 +++ tests/test_home_network_intrusion_profile.py | 88 +++ tests/test_lan_common_neighbors.py | 136 +++++ tests/test_module_arp_spoof_check.py | 158 +++++ ...test_module_browser_cryptojacking_check.py | 199 +++++++ tests/test_module_crypto_miner_persistence.py | 243 ++++++++ tests/test_module_lan_device_inventory.py | 138 +++++ tests/test_module_router_security_audit.py | 118 ++++ tests/test_module_stalkerware_scan.py | 191 ++++++ tests/test_module_win_crypto_miner_detect.py | 152 +++++ ...est_module_win_scheduled_tasks_security.py | 41 +- tests/test_runtime.py | 56 ++ 43 files changed, 6302 insertions(+), 23 deletions(-) create mode 100644 guides/home_network_intrusion/phase_0.md create mode 100644 guides/home_network_intrusion/phase_1.md create mode 100644 guides/home_network_intrusion/phase_2.md create mode 100644 guides/home_network_intrusion/phase_3.md create mode 100644 guides/home_network_intrusion/phase_4.md create mode 100644 modules/network/arp_spoof_check/__init__.py create mode 100644 modules/network/lan_common/__init__.py create mode 100644 modules/network/lan_common/neighbors.py create mode 100644 modules/network/lan_common/oui_vendors.json create mode 100644 modules/network/lan_device_inventory/__init__.py create mode 100644 modules/network/router_security_audit/__init__.py create mode 100644 modules/security/browser_cryptojacking_check/__init__.py create mode 100644 modules/security/crypto_miner_persistence/__init__.py create mode 100644 modules/security/cryptojacking_iocs/__init__.py create mode 100644 modules/security/cryptojacking_iocs/browser_miners.json create mode 100644 modules/security/cryptojacking_iocs/known_miners.json create mode 100644 modules/security/cryptojacking_iocs/known_pools.json create mode 100644 modules/security/cryptojacking_iocs/loader.py create mode 100644 modules/security/cryptojacking_iocs/manifest.json create mode 100644 modules/security/stalkerware_scan/__init__.py create mode 100644 modules/security/stalkerware_scan/data/known_stalkerware.json create mode 100644 modules/security/win_crypto_miner_detect/__init__.py create mode 100644 profiles/home_network_intrusion.yaml create mode 100644 tests/test_cryptojacking_iocs.py create mode 100644 tests/test_home_network_intrusion_profile.py create mode 100644 tests/test_lan_common_neighbors.py create mode 100644 tests/test_module_arp_spoof_check.py create mode 100644 tests/test_module_browser_cryptojacking_check.py create mode 100644 tests/test_module_crypto_miner_persistence.py create mode 100644 tests/test_module_lan_device_inventory.py create mode 100644 tests/test_module_router_security_audit.py create mode 100644 tests/test_module_stalkerware_scan.py create mode 100644 tests/test_module_win_crypto_miner_detect.py 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/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..2281ef7 --- /dev/null +++ b/modules/security/stalkerware_scan/__init__.py @@ -0,0 +1,407 @@ +"""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 — in the US, the National Domestic Violence Hotline is " + "1-800-799-7233, and the Coalition Against Stalkerware " + "(stopstalkerware.org) lists services in other countries. They can help " + "you plan the order in which to do this safely." +) + + +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/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/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..dad8f0a --- /dev/null +++ b/tests/test_home_network_intrusion_profile.py @@ -0,0 +1,88 @@ +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_five_phases_in_order(): + guides = discover_guides(GUIDES_DIR, "home_network_intrusion") + assert [g.phase for g in guides] == [0, 1, 2, 3, 4] + + +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_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 From 3eb3912237a1fdecc6515f1b8254b3351c2a0e53 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 01:59:49 +0000 Subject: [PATCH 2/3] feat: add identity theft recovery walkthrough and profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a six-phase guide that doubles as a recovery checklist — `rescue guide identity_theft_recovery` prints the outstanding steps and `--complete ` marks them off, so progress survives across the weeks this actually takes. The phases are ordered the way recovery works rather than the way it feels: 0. The first hour — start a recovery log, write down what is known, and deal with money that has already moved (reporting windows for unauthorised debit charges are short). 1. Confirm the device is not the leak — the only automatable phase, because changing passwords on a machine with a keylogger hands them straight back. Also covers email forwarding rules and carrier port-out locks, which both survive a password change. 2. Freeze — all three credit bureaus plus Innovis, NCTUE, and ChexSystems, which is where phone contracts and bank accounts get opened. 3. Report — IdentityTheft.gov first, because the FTC identity theft report is what unlocks the §605B block and the seven-year extended alert. Then police, IRS/state tax, SSA, CFPB, and the channels specific to SIM swap, medical, criminal, child, and deceased-relative identity theft. 4. Dispute — the distinction between a dispute (30 days, contestable) and an identity theft block (four business days, backed by the FTC report), plus the follow-up schedule for accounts that reappear via a new collector. 5. Monitor and rebuild — the long tail, and what recurrence looks like. The profile pairs the guide with the twelve existing modules that answer the one technical question here: is this device leaking credentials. It claims no automation for anything that happens at a bank or a government agency. US-first, with equivalents for the UK, Canada, Australia, and the EU in phase 2, and free case-managed support services named in phase 5. Also regenerates the integrity manifest for the runtime.py change in the previous commit, which was making every launch print a tamper warning. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AsDpjoDi59AXxhoy8TriUQ --- guides/identity_theft_recovery/phase_0.md | 63 ++++++++++ guides/identity_theft_recovery/phase_1.md | 60 +++++++++ guides/identity_theft_recovery/phase_2.md | 99 +++++++++++++++ guides/identity_theft_recovery/phase_3.md | 105 ++++++++++++++++ guides/identity_theft_recovery/phase_4.md | 83 ++++++++++++ guides/identity_theft_recovery/phase_5.md | 86 +++++++++++++ profiles/identity_theft_recovery.yaml | 35 ++++++ rescue/security/integrity_manifest.json | 2 +- tests/test_identity_theft_recovery_profile.py | 119 ++++++++++++++++++ 9 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 guides/identity_theft_recovery/phase_0.md create mode 100644 guides/identity_theft_recovery/phase_1.md create mode 100644 guides/identity_theft_recovery/phase_2.md create mode 100644 guides/identity_theft_recovery/phase_3.md create mode 100644 guides/identity_theft_recovery/phase_4.md create mode 100644 guides/identity_theft_recovery/phase_5.md create mode 100644 profiles/identity_theft_recovery.yaml create mode 100644 tests/test_identity_theft_recovery_profile.py 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/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/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_identity_theft_recovery_profile.py b/tests/test_identity_theft_recovery_profile.py new file mode 100644 index 0000000..a940b51 --- /dev/null +++ b/tests/test_identity_theft_recovery_profile.py @@ -0,0 +1,119 @@ +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_six_phases_in_order(): + assert [g.phase for g in _guides()] == [0, 1, 2, 3, 4, 5] + + +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 From 8dcedc06370522ef63a106d59780793595a2d069 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 04:53:56 +0000 Subject: [PATCH 3/3] feat: add resources and tipline reference phases to both recovery guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both guides had helplines scattered inline across steps, which is where they are least findable — somebody who needs a number six weeks from now is not going to re-read phase 3 step 4 to find it. Each guide now ends with a dedicated reference phase. identity_theft_recovery phase 6 covers free case-managed help (Identity Theft Resource Center, the AARP helpline, the DOJ elder fraud hotline, IDCARE, the Access Now digital security helpline), the official reporting channels, all six credit bureaus and databases that accept freezes, abuse-specific support including coerced debt, free legal aid, and non-US equivalents. home_network_intrusion phase 5 covers abuse support first — because when the intruder is someone known, the safety plan changes the order of the technical work — then free incident response, where to report an intrusion, a pointer to the identity theft guide when accounts are involved, and how to tell whether a router is worth keeping. Both open with the same warning: search results for support numbers are bought by scam call centres, and someone mid-recovery is exactly who they want. Type the domain, use the number on the card, and nothing legitimate is ever paid for in gift cards. That warning is worthless below the list, so it is step 1. stalkerware_scan's safety guidance gains the same country-by-country numbers, plus a note not to look them up from the monitored device — browsing history is one of the things that gets reported. A test asserts the scam-helpline warning stays first in the resources phase, and the step-title check caught one heading too vague to work as a checklist item. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AsDpjoDi59AXxhoy8TriUQ --- guides/home_network_intrusion/phase_5.md | 120 ++++++++++++++ guides/identity_theft_recovery/phase_6.md | 150 ++++++++++++++++++ modules/security/stalkerware_scan/__init__.py | 17 +- tests/test_home_network_intrusion_profile.py | 19 ++- tests/test_identity_theft_recovery_profile.py | 29 +++- 5 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 guides/home_network_intrusion/phase_5.md create mode 100644 guides/identity_theft_recovery/phase_6.md 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_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/security/stalkerware_scan/__init__.py b/modules/security/stalkerware_scan/__init__.py index 2281ef7..33a0787 100644 --- a/modules/security/stalkerware_scan/__init__.py +++ b/modules/security/stalkerware_scan/__init__.py @@ -71,10 +71,19 @@ "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 — in the US, the National Domestic Violence Hotline is " - "1-800-799-7233, and the Coalition Against Stalkerware " - "(stopstalkerware.org) lists services in other countries. They can help " - "you plan the order in which to do this safely." + "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." ) diff --git a/tests/test_home_network_intrusion_profile.py b/tests/test_home_network_intrusion_profile.py index dad8f0a..6742476 100644 --- a/tests/test_home_network_intrusion_profile.py +++ b/tests/test_home_network_intrusion_profile.py @@ -52,9 +52,24 @@ def test_profile_covers_network_cryptojacking_and_monitoring(): assert {"stalkerware_scan", "remote_login_check"} <= matched -def test_guide_has_all_five_phases_in_order(): +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] + 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(): diff --git a/tests/test_identity_theft_recovery_profile.py b/tests/test_identity_theft_recovery_profile.py index a940b51..ccdcb6e 100644 --- a/tests/test_identity_theft_recovery_profile.py +++ b/tests/test_identity_theft_recovery_profile.py @@ -46,8 +46,33 @@ def test_profile_answers_whether_the_device_is_leaking_credentials(): assert {"remote_login_check", "win_remote_access_audit"} <= matched -def test_guide_has_six_phases_in_order(): - assert [g.phase for g in _guides()] == [0, 1, 2, 3, 4, 5] +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():