diff --git a/desktop/main.js b/desktop/main.js index 35d843b..5f2d042 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -1,6 +1,7 @@ const { app, BrowserWindow, ipcMain, shell } = require('electron'); const path = require('path'); const { runScan } = require('./engine-runner'); +const { isAllowedSettingUrl } = require('./setting-urls'); function createWindow() { const win = new BrowserWindow({ @@ -9,13 +10,25 @@ function createWindow() { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, + sandbox: true, }, }); win.loadFile(path.join(__dirname, 'renderer', 'index.html')); + + // The renderer only ever loads the bundled local page; it has no reason to + // navigate away or spawn windows. + win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + win.webContents.on('will-navigate', (event) => event.preventDefault()); } app.whenReady().then(() => { ipcMain.handle('run-scan', () => runScan()); - ipcMain.handle('open-setting', (_e, url) => shell.openExternal(url)); + ipcMain.handle('open-setting', (_e, url) => { + if (!isAllowedSettingUrl(url)) { + return false; + } + shell.openExternal(url); + return true; + }); createWindow(); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); diff --git a/desktop/package.json b/desktop/package.json index 9f65edc..945f67a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -3,6 +3,6 @@ "version": "0.1.0", "description": "Multiverse Device Rescue desktop app", "main": "main.js", - "scripts": { "start": "electron ." }, + "scripts": { "start": "electron .", "test": "node --test test/*.test.js" }, "devDependencies": { "electron": "^32.0.0", "electron-builder": "^25.0.0" } } diff --git a/desktop/setting-urls.js b/desktop/setting-urls.js new file mode 100644 index 0000000..3ad6134 --- /dev/null +++ b/desktop/setting-urls.js @@ -0,0 +1,17 @@ +// Allowlist for the 'open-setting' IPC channel. +// +// shell.openExternal hands a URL straight to the OS handler, so an unrestricted +// channel would let anything that can reach the renderer — a future markup +// injection bug, or a tampered permissions-content.json arriving through the +// content-update path — launch local files and registered protocol handlers. +// These are the only schemes the permissions walkthrough actually needs. +const ALLOWED_SETTING_SCHEMES = ['x-apple.systempreferences:', 'ms-settings:']; + +function isAllowedSettingUrl(url) { + if (typeof url !== 'string') return false; + // Match on the exact scheme prefix. Nothing is stripped or rewritten first: + // sanitising a URL invites parser-differential bugs, allowlisting does not. + return ALLOWED_SETTING_SCHEMES.some((scheme) => url.startsWith(scheme)); +} + +module.exports = { isAllowedSettingUrl, ALLOWED_SETTING_SCHEMES }; diff --git a/desktop/test/setting-urls.test.js b/desktop/test/setting-urls.test.js new file mode 100644 index 0000000..77b0de1 --- /dev/null +++ b/desktop/test/setting-urls.test.js @@ -0,0 +1,54 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { isAllowedSettingUrl } = require('../setting-urls'); + +test('allows the macOS settings-pane URLs the walkthrough ships', () => { + assert.strictEqual( + isAllowedSettingUrl('x-apple.systempreferences:com.apple.preference.security'), + true + ); + assert.strictEqual( + isAllowedSettingUrl( + 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles' + ), + true + ); +}); + +test('allows the Windows settings scheme', () => { + assert.strictEqual(isAllowedSettingUrl('ms-settings:privacy-webcam'), true); +}); + +test('rejects schemes that could launch local code', () => { + for (const url of [ + 'file:///etc/passwd', + 'file:///C:/Windows/System32/cmd.exe', + 'vscode://file/etc/passwd', + 'smb://attacker.example/share', + 'javascript:alert(1)', + 'data:text/html,', + 'http://attacker.example', + 'https://attacker.example', + ]) { + assert.strictEqual(isAllowedSettingUrl(url), false, `should reject ${url}`); + } +}); + +test('rejects near-miss strings that only embed an allowed scheme', () => { + // startsWith, not includes — an allowed scheme appearing later in the string + // must not qualify the URL. + assert.strictEqual( + isAllowedSettingUrl('file:///tmp/x#x-apple.systempreferences:'), + false + ); + assert.strictEqual( + isAllowedSettingUrl(' x-apple.systempreferences:com.apple.preference.security'), + false + ); +}); + +test('rejects non-string input', () => { + for (const value of [null, undefined, 42, {}, [], true]) { + assert.strictEqual(isAllowedSettingUrl(value), false); + } +}); diff --git a/docs/ROADMAP_STATUS.md b/docs/ROADMAP_STATUS.md index 3564719..89b815f 100644 --- a/docs/ROADMAP_STATUS.md +++ b/docs/ROADMAP_STATUS.md @@ -83,3 +83,117 @@ cd /tmp && /tmp/rv/bin/rescue profiles # discovers shipped content /tmp/rv/bin/rescue run disk_space --yes # end-to-end read-only check python scripts/generate_integrity_manifest.py # then startup verifies clean ``` + +--- + +# Update — 2026-08-05: branch consolidation, audit, and planned modules + +This section corrects several claims above that did not hold on `main`, and +records what changed. Where an earlier claim was wrong, it is called out rather +than quietly edited, because "we verified this" appearing next to something +untrue is the failure mode worth avoiding. + +## Corrections to the status above + +**P0#4 (self-integrity) was recorded as "DONE — manifest regenerated; verifies +clean". It did not verify clean on `main`.** `verify_package_integrity` reported +8 tampered and 5 added files, so startup verification failed and every launch +printed a tamper warning. The regeneration was real, but it happened on a +different lineage; `main`'s own commits then added `remediation.py`, +`serialize.py`, `threat_map.py`, and two TUI screens without regenerating. +Regenerated here, and it now verifies clean. + +**P0#8 (security-reset profile) was recorded as DONE.** It was made *valid* by +deleting the references to `password_manager_check`, `twofa_audit`, and +`session_revocation_scan` — the profile stopped naming modules that did not +exist, but the capability was never built. Those three modules now exist and the +profile references them again. + +**The test-suite baseline was recorded as "3094 passing".** The suite was not +green: it was 64 failed / 3341 passed, and one file did not even parse under the +declared minimum Python. See below. + +## Python version support was broken, not partially broken + +`pyproject.toml` declares `requires-python = ">=3.11"`. On 3.11: + +- 3 files failed to parse at all (PEP 701 nested-quote f-strings, and a + backslash inside an f-string expression). Two were shipped modules, so module + discovery broke. +- 30 call sites across 16 modules passed `follow_symlinks=` to + `pathlib.Path.is_file()` / `is_dir()`. That keyword arrived in **3.13**, so + every one raised `TypeError` at runtime. Those modules did not work on two of + the three supported versions. + +Both are fixed; `rescue.fsbounds` now exposes `is_file_nofollow` / +`is_dir_nofollow` with the same semantics on every supported version. + +**Nothing was catching this.** There is no `.github/` directory and no CI. A +matrix that actually ran the suite on 3.11 would have caught the largest defect +in the tree. This is the highest-value remaining infrastructure gap. + +## The 64 failures were environment coupling, not flakiness + +Every one was a test that silently depended on the machine it ran on, so the +suite only passed on one developer's macOS box: + +- Modules that check a real path under `~/Library` or `/Library` and return + early if absent, so `check()` short-circuited before the mocked subprocess was + reached (`notification_center_check`, `kext_audit`, + `appleid_security_check`). +- A time bomb: `win_safe_mode_check` used absolute dates chosen to be "5 days + ago" when written. Real time moved past them and the fixture aged into a + >30-day uptime, tripping the warning it asserts is absent. +- `disk_permissions_repair` asserted ownership against a hardcoded uid 501, so + it only passed as a typical macOS user account. +- `test_cli_scan_json` used `CliRunner(mix_stderr=...)`, removed in click 8.2. +- `update/test_verify` signed a tag without pinning `gpg.format`, so on a host + configured for SSH commit signing it produced an SSH signature while asserting + on the GPG path. **No trust check was weakened to green this**; the + verification code was correct and only the test changed. + +The pattern is now a convention: a module's traversal roots are class attributes +so tests can point them at a fixture tree. + +Full suite: **3503 passed, 0 failed.** + +## Planned modules — resolved + +"Planned but missing" was resolved against the roadmap, not guessed. Profiles, +guides, and the threat map reference no missing modules, and every module named +in `docs/superpowers/plans/` exists. Two sources yielded real gaps, both now +closed: + +| Module | Source | Why it exists | +| --- | --- | --- | +| `password_manager_check` | P0#8 | Named, never built | +| `twofa_audit` | P0#8 | Named, never built | +| `session_revocation_scan` | P0#8 | Named, never built | +| `code_signature_audit` | P2 "Trust and reputation verification" | No signature validation existed | +| `security_baseline_diff` | P2 "Baselines and differential scans" | No baseline capability existed | +| `evidence_bundle` | P2 "Evidence collection and forensic handoff" | Nothing preserved evidence before repair | + +Registry now discovers **278** modules. The remaining P2 rows are framework- or +guide-level rather than module-shaped (transactional remediation, offline and +bootable recovery, incident triage), or already have substantial module coverage +(backup validation, hardware recovery), and are deliberately out of scope here. + +## Remaining work, with current numbers + +These were already recorded above as follow-ups. The counts have **grown** since +the roadmap was written, which is worth knowing before scheduling them: + +- **P0#7 command-runner migration has not started.** 756 `subprocess.run` calls + in modules, **0** routed through `rescue.command.run`, 435 with no timeout. + The roadmap counted 744 and 395. +- **Remediation codes are ~38% migrated.** 169 of 272 pre-existing modules + declare no `emits_codes`; 30% of `Finding()` constructions set `code=`. + (`tests/test_module_code_consistency.py` does guard the ones that do.) +- **No CI**, as above. + +Verified healthy, for balance: `auto_apply = True` appears in **zero** modules, +so auto mode genuinely is read-only as P0#5 intends; there are no +`NotImplementedError` stubs or placeholder TODOs; the only skipped tests are +correctly conditional on `gpg` being available; and both `setup.py` and +`rescue.spec` glob the content directories, so new modules ship without +packaging changes. diff --git a/guides/home_network_intrusion/phase_0.md b/guides/home_network_intrusion/phase_0.md new file mode 100644 index 0000000..2f49dac --- /dev/null +++ b/guides/home_network_intrusion/phase_0.md @@ -0,0 +1,54 @@ +--- +profile: home_network_intrusion +phase: 0 +title: "Before You Touch Anything" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4] +estimated_time: "15 minutes" +--- + +## Step 1: Decide whether this is a safety situation first + +If the person who may have got in is someone you know — a partner, an ex, a +housemate, a family member — stop and read this before doing anything else. + +Cutting off someone's access tells them they have been found out. If that +person has ever frightened you, the safest order of operations is different +from the technical one, and it is worth getting help planning it. In the US, +the National Domestic Violence Hotline is 1-800-799-7233. The Coalition +Against Stalkerware (stopstalkerware.org) lists services in other countries. + +If it is a stranger, a neighbour, or you have no idea, carry on to Step 2. + +## Step 2: Write down what made you suspicious + +On paper, or on a device that has never been on this network. Slow computers, +fans running constantly, a hot laptop doing nothing, unfamiliar devices in the +router's list, logins from places you have not been, someone knowing things +they should not. Note dates if you have them. + +This matters for two reasons: it is what tells you whether the problem is +fixed afterwards, and it is the beginning of a record if this ever becomes a +police report or a legal matter. + +## Step 3: Find a device and a network you can trust + +You will need somewhere safe to change passwords from, and it cannot be a +device on the affected network. A phone on mobile data — with mobile data on +and Wi-Fi switched off — is usually the easiest option. Use it for account +changes throughout this guide. + +Do not change important passwords from a machine you have not yet cleaned. If +something on it is recording keystrokes, you are handing over the new password +as you type it. + +## Step 4: Understand the order and why it matters + +The rest of this guide goes: reclaim the network, then clean the devices, then +rebuild the accounts. + +That order is deliberate. Cleaning a laptop first and then reconnecting it to +a network someone else still controls means starting again. Changing passwords +first, on an unclean machine or an intercepted network, means handing over the +new ones. It is slower to do it in this order and much faster than doing it +twice. diff --git a/guides/home_network_intrusion/phase_1.md b/guides/home_network_intrusion/phase_1.md new file mode 100644 index 0000000..6674748 --- /dev/null +++ b/guides/home_network_intrusion/phase_1.md @@ -0,0 +1,62 @@ +--- +profile: home_network_intrusion +phase: 1 +title: "See Who Is On The Network" +automatable_steps: [1, 2] +human_only_steps: [3, 4, 5] +estimated_time: "45 minutes" +--- + +## Step 1: Inventory the devices on the local network + +Run the profile's local-network checks. `lan_device_inventory` lists every +device that has recently talked to this computer, with the manufacturer behind +each hardware address where it can be identified. + +Two things to know before reading the list. It only shows devices that have +been active recently, so it can be incomplete — the router's own admin page is +the authoritative list. And modern phones deliberately use a different, +randomly generated hardware address on every network, so a phone will not +match the address printed on its box. That is privacy behaviour, not an +intruder. + +## Step 2: Check whether anything is intercepting traffic + +`arp_spoof_check` looks for the specific pattern that traffic interception +leaves behind: one device answering for addresses that belong to others, +usually the router's. + +If it reports gateway impersonation, treat the network as actively monitored. +Stop using it for anything sensitive until Phase 2 is done — no logins, no +password changes, no banking. Switch to mobile data for those. + +Mesh systems and Wi-Fi extenders can produce the same pattern legitimately. If +you have one, that is the likely explanation; confirm it before panicking. + +## Step 3: Name every device on the list + +Go through the inventory one line at a time and say what each device is out +loud: phone, laptop, TV, printer, thermostat, doorbell, games console, smart +plug. Most households are surprised by the count — twenty is normal now. + +For anything you cannot name, unplug or power off a suspected device and +re-run Step 1. The entry that disappears is that device. + +## Step 4: Cross-check against the router's own device list + +Sign in to the router (Phase 2 covers how) and open its list of connected +devices — it may be called Attached Devices, Device List, Client List, or +DHCP Clients. It shows devices your computer has not spoken to, which the scan +in Step 1 cannot see. + +Compare the two lists. Anything on the router's list that you cannot account +for is the thing to focus on. + +## Step 5: Do not bother blocking devices by hardware address + +Most routers offer MAC filtering, and it feels like the obvious answer. It is +not: a hardware address can be changed in seconds, and blocking one only tells +the intruder which address to stop using. + +The change that actually removes everyone you have not authorised is a new +Wi-Fi passphrase, which is Phase 2. diff --git a/guides/home_network_intrusion/phase_2.md b/guides/home_network_intrusion/phase_2.md new file mode 100644 index 0000000..818250f --- /dev/null +++ b/guides/home_network_intrusion/phase_2.md @@ -0,0 +1,88 @@ +--- +profile: home_network_intrusion +phase: 2 +title: "Take The Router Back" +automatable_steps: [1] +human_only_steps: [2, 3, 4, 5, 6, 7, 8] +estimated_time: "1 hour" +--- + +## Step 1: Audit what the router is offering to the network + +Run `router_security_audit`. It checks which administrative services your +router is offering to every device on the Wi-Fi — remote login services, the +admin page, provider management interfaces — and whether UPnP is enabled. + +Everything after this step happens in the router's own settings, because the +settings that decide whether someone can get back in are not visible from +outside it. + +## Step 2: Sign in to the router, ideally over a cable + +Connect a computer to the router with an Ethernet cable if you can, so the +next steps do not travel over a network someone else may be watching. Then +open the gateway address the audit reported in a browser. + +If the admin password is still the one printed on the underside of the router, +assume the router has already been reconfigured by someone else, and read +every setting below rather than trusting any of them. + +## Step 3: Update the firmware first + +Look for Firmware Update, Router Update, or Administration. Install whatever +is offered and let it reboot. + +Do this before anything else. If the router has a known vulnerability, new +passwords do not help — the way in was never the password. + +## Step 4: Change the admin password and the Wi-Fi passphrase + +Two different passwords, both long, neither used anywhere else: + +- The **admin password** protects the router's settings. +- The **Wi-Fi passphrase** is what devices use to join. + +Set the wireless security mode to WPA3. If the router does not offer it, +WPA2-AES (sometimes shown as WPA2-PSK AES) is acceptable. Never WEP, never +WPA/TKIP, never open — those are broken and can be cracked in minutes by +someone parked outside. + +## Step 5: Turn off WPS + +WPS lets a device join using an eight-digit PIN instead of the passphrase, and +that PIN can be broken offline. Leaving it on makes the strong passphrase you +just set irrelevant. Turn it off. + +## Step 6: Turn off remote administration, and check the DNS servers + +Find Remote Management, Remote Access, Web Access from WAN, or cloud +management, and turn it off. The router's settings should only be reachable +from inside the house. + +Then open the Internet or WAN page and look at the DNS servers. They should be +your provider's, or a resolver you deliberately chose (for example 1.1.1.1 or +9.9.9.9). Anything else means someone redirected every device in the house to +a name server they control — every phone, TV, and laptop, without touching any +of them. Reset it if it is not what you expect. + +## Step 7: Clear out port forwarding, DMZ, and the guest network + +Delete every port-forwarding rule you did not create yourself, and switch off +any "DMZ host". These are open doors from the internet to a specific device +inside the house, and UPnP can create them automatically on a program's +request. + +Check the guest network too. An open guest network with no password is a +second way onto the same hardware. + +## Step 8: If the settings will not stick, factory reset + +If changes do not save, or old settings reappear, the router itself is +compromised. Hold the reset pin for 30 seconds to factory reset it, then set it +up again from scratch. + +Do not restore a saved configuration backup — that restores the intruder's +changes along with yours. Set it up by hand. + +When the router is done, re-run Step 1 and Phase 1 to confirm the network +looks the way you expect. diff --git a/guides/home_network_intrusion/phase_3.md b/guides/home_network_intrusion/phase_3.md new file mode 100644 index 0000000..cc8bbc4 --- /dev/null +++ b/guides/home_network_intrusion/phase_3.md @@ -0,0 +1,92 @@ +--- +profile: home_network_intrusion +phase: 3 +title: "Clean The Devices" +automatable_steps: [1, 2, 3, 4] +human_only_steps: [5, 6, 7] +estimated_time: "1-2 hours per device" +--- + +## Step 1: Look for cryptocurrency mining + +Run `crypto_miner_detect` (macOS) or `win_crypto_miner_detect` (Windows). +These look at what is running right now: miner process names, command lines +containing a mining pool or a wallet address, and live connections to mining +pool ports. + +Mining is what a hot, loud, slow machine usually turns out to be. It is also +rarely the first thing that happened — it is what someone does with access +they already had, which is why the rest of this guide exists. + +## Step 2: Find what restarts the miner + +Run `crypto_miner_persistence`. Killing a miner achieves nothing if a startup +entry launches it again a minute later, and that is the normal arrangement. + +This check looks at startup items, scheduled tasks, cron entries, service +definitions, and shell startup files for mining commands, and for the miner's +own configuration file — the one containing the pool address and the wallet +being paid. + +Note down the wallet address before deleting anything. It is the clearest +evidence of what was happening. + +## Step 3: Check the browser + +Run `browser_cryptojacking_check`. Mining does not need to be a program: a +browser extension can mine for as long as the browser is open, and process +lists will only ever show Chrome. + +This checks installed extensions for mining code, startup pages set to mining +sites, and hosts-file entries pointing mining domains somewhere unexpected. + +## Step 4: Check who else has access to the machine + +Run `stalkerware_scan`, `remote_login_check` (macOS) or +`win_remote_access_audit` (Windows), and `process_scanner`. + +Three different things come out of this, and they need to be told apart: + +- **Monitoring software** sold for watching another person. If nobody told you + it was there, someone installed it to watch what you do. Re-read Phase 0 + Step 1 before removing it. +- **Remote access tools** — TeamViewer, AnyDesk, VNC, ScreenConnect. Ordinary + software that gives someone complete control of the screen. Common leftovers + from "tech support" phone scams. +- **Adware and fake cleaners**, which are a nuisance rather than surveillance, + but tend to arrive by the same route. + +## Step 5: Remove in the right order + +For each thing found: + +1. Record it first — a screenshot, the file path, the date. +2. Remove the startup entry, then reboot. +3. Delete the program itself. +4. Re-run the checks. + +If something reappears after a reboot, stop. Something else on the machine +still has enough privilege to rebuild it, and removing symptoms one at a time +will not get ahead of it. That is the point at which a full operating-system +reinstall is the faster and more certain option. + +## Step 6: Do every device, not just the interesting one + +Everything that was on the network needs looking at: every laptop and desktop, +phones and tablets, and the devices nobody thinks of as computers — the TV, +the streaming stick, the cameras, the printer, the smart speakers. + +For the ones you cannot scan, the practical action is the same: install +pending updates, change any password associated with them, and factory reset +anything that behaves oddly. Cameras and video doorbells deserve particular +attention, because access to them is access to the inside of the house. + +## Step 7: Consider a clean reinstall for the worst device + +A full reinstall is the only way to be certain, and on the machine that was +most affected it is often less work than repeatedly chasing things that come +back. + +If you do it: back up documents and photos only, never applications or system +settings, and never restore a full system image made while the machine was +compromised. diff --git a/guides/home_network_intrusion/phase_4.md b/guides/home_network_intrusion/phase_4.md new file mode 100644 index 0000000..c2956c9 --- /dev/null +++ b/guides/home_network_intrusion/phase_4.md @@ -0,0 +1,69 @@ +--- +profile: home_network_intrusion +phase: 4 +title: "Rebuild Accounts And Keep Them Out" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6] +estimated_time: "2 hours, then 15 minutes a month" +--- + +## Step 1: Only now, change the passwords + +The network is reclaimed and the devices are clean, so a new password will not +be captured the moment it is typed. Work from a device you trust. + +Order matters: email first, because it is the reset path for everything else. +Then banking and payment accounts, then anything storing personal data, then +the rest. Each password long, unique, and stored in a password manager rather +than remembered. + +## Step 2: Turn on two-factor authentication, starting with email + +Use an authenticator app rather than SMS where the choice exists — text +messages can be redirected by someone who can talk a phone company into moving +a number. + +Save the recovery codes somewhere that is not the device you are protecting. + +## Step 3: Sign out everything else + +Every major service has a page listing active sessions and connected devices — +look for "Where you're signed in", "Devices", or "Security activity". Sign out +of everything, everywhere, and check the list of connected apps while you are +there. + +A password change does not always end sessions that are already open. This is +what actually removes someone who was still signed in. + +## Step 4: Check the accounts attached to the house, not just to you + +These get missed, and they are how people get back in: + +- The router's own cloud or mobile-app account. +- Cameras, doorbells, thermostats, smart plugs, and speakers. +- Streaming and TV accounts. +- Any shared family account, and any account where the recovery email or + phone number is someone else's. + +For each, change the password and look at who else it is shared with. + +## Step 5: Separate the things that do not need to be together + +Two changes that make the next attempt much harder: + +- Put smart-home gadgets and cameras on the guest network, with its own + password. Cheap devices are the usual way in, and the guest network keeps + them away from your laptops and files. +- Keep the router's admin password different from the Wi-Fi password, so + giving a visitor the Wi-Fi password does not give away the router. + +## Step 6: Set a date to check again + +Put a reminder in the calendar for a month's time and re-run this profile. +What you are looking for is whether the device list has grown, whether anything +is intercepting traffic again, and whether mining or monitoring software has +come back. + +If it has come back after all of this, the entry point was never found. That is +the point to bring in a professional rather than going round again — and if the +person who got in is someone you know, to go back to Phase 0, Step 1. diff --git a/guides/home_network_intrusion/phase_5.md b/guides/home_network_intrusion/phase_5.md new file mode 100644 index 0000000..6c556ce --- /dev/null +++ b/guides/home_network_intrusion/phase_5.md @@ -0,0 +1,120 @@ +--- +profile: home_network_intrusion +phase: 5 +title: "Resources, Tiplines, And Free Help" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6] +estimated_time: "reference — use as needed" +--- + +## Step 1: Do not call a support number you found by searching + +Search adverts for "router support", "remove virus", and "Wi-Fi security help" +are routinely bought by scam call centres. Their entire business is people in +exactly this situation. The call ends with remote access software installed on +the machine and a payment made — which is how a fair number of the compromises +this toolkit finds got started. + +Type the official domain yourself, or use the number printed on the device or +on a paper bill. Nobody legitimate asks for payment in gift cards, +cryptocurrency, wire transfers, or a payment app. + +Details below were accurate at the time of writing; verify on the official +site if something looks different. + +## Step 2: If the person who got in is someone you know + +This changes the order of everything. Removing their access tells them they +have been found out, and that is the point at which situations escalate. Talk +to an advocate before making the technical changes, not after. + +- **National Domestic Violence Hotline** (US) — 1-800-799-7233, TTY + 1-800-787-3224, or text START to 88788 — thehotline.org +- **NNEDV Safety Net** — techsafety.org — the specialists on + technology-facilitated abuse: shared accounts, location tracking, smart-home + devices, and stalkerware +- **Coalition Against Stalkerware** — stopstalkerware.org — international + directory of services +- **SPARC** — stalkingawareness.org +- **Operation Safe Escape** — safeescape.org — practical security support for + people leaving abusive situations +- **RAINN** — 1-800-656-4673 +- **UK** — National Domestic Abuse Helpline 0808 2000 247; Refuge's tech abuse + team at refugetechsafety.org +- **Australia** — 1800RESPECT, 1800 737 732; eSafety Commissioner, + esafety.gov.au + +Smart-home devices deserve specific mention: thermostats, cameras, doorbells, +locks, and speakers are often still linked to an account the other person +controls. An advocate will help you work out the safe order to unpick that. + +## Step 3: Free expert help with a compromised device or network + +- **Access Now Digital Security Helpline** — accessnow.org/help — free, 24/7, + nine languages, for journalists, activists, human rights defenders, and + civil society. Real incident responders. +- **Citizen Lab** and **Amnesty International Security Lab** — for anyone who + may be targeted by state-grade or mercenary spyware; both accept referrals + and publish forensic tooling. +- **CISA** (US) — cisa.gov/report — report@cisa.gov — 1-844-729-2472. +- **Consumer Reports Security Planner** — + securityplanner.consumerreports.org — free personalised hardening checklist. +- **EFF Surveillance Self-Defense** — ssd.eff.org — plain-language guides on + threat modelling and network security. + +## Step 4: Where to report the intrusion + +Reporting rarely gets one household's router fixed, but it is what makes +patterns visible, and some reports do get acted on. + +- **FBI Internet Crime Complaint Center** — ic3.gov — the main US channel for + computer intrusion. +- **ReportFraud.ftc.gov** — for scams, tech-support fraud, and unauthorised + charges. +- **Your internet provider's abuse team** — worth a call if the intrusion came + through provider-supplied equipment, or if provider remote management + (TR-069) is exposed. They can push firmware and reset the device remotely. +- **Local police** — necessary if there is any stalking, harassment, or + threat involved, and required before some other protections become + available. +- **UK** — Action Fraud, 0300 123 2040, actionfraud.police.uk; NCSC at + ncsc.gov.uk/report. +- **Canada** — Canadian Anti-Fraud Centre, 1-888-495-8501; Canadian Centre for + Cyber Security. +- **Australia/NZ** — ReportCyber at cyber.gov.au; CERT NZ; IDCARE at + idcare.org, 1800 595 160 (AU) / 0800 121 068 (NZ). + +## Step 5: If money or accounts were touched, switch guides + +Network intrusion that reaches accounts becomes a different job with its own +deadlines — freezes, official reports, and disputes, in that order. + +Run `rescue guide identity_theft_recovery` for the full sequence. The numbers +that matter most quickly: + +- **IdentityTheft.gov** (FTC) — 1-877-438-4338 — produces the report that + obliges credit bureaus and creditors to act +- **Identity Theft Resource Center** — idtheftcenter.org — 1-888-400-5530 — + free advisors who stay with your case +- Credit freezes: Equifax 1-800-685-1111, Experian 1-888-397-3742, TransUnion + 1-888-909-8872 — free, and the fastest way to stop new damage +- Your bank's fraud line, from the back of the card — not from a search result + +## Step 6: Understanding the equipment and the failure modes + +Useful when deciding whether a router is worth keeping: + +- **Router firmware and end-of-life status** — check the manufacturer's + support page for your exact model. A router no longer receiving security + updates cannot be made safe by configuration, and replacing it is the fix. +- **routersecurity.org** — an independently maintained, vendor-neutral + reference on router settings and their trade-offs. +- **CISA Known Exploited Vulnerabilities catalogue** — cisa.gov/kev — searching + it for your router's make tells you whether its flaws are actively being + used. +- **haveibeenpwned.com** — which breaches include your addresses, which often + explains where the Wi-Fi password was reused from. + +If unknown devices keep reappearing after a factory reset and a firmware +update, stop troubleshooting and replace the router. At that point the cost of +a new one is lower than the cost of continuing to guess. diff --git a/guides/identity_theft_recovery/phase_0.md b/guides/identity_theft_recovery/phase_0.md new file mode 100644 index 0000000..991bf7d --- /dev/null +++ b/guides/identity_theft_recovery/phase_0.md @@ -0,0 +1,63 @@ +--- +profile: identity_theft_recovery +phase: 0 +title: "The First Hour" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5] +estimated_time: "1 hour" +--- + +## Step 1: Start a recovery log before you do anything else + +Open a notebook, or a document on a device you trust, and put today's date at +the top. From here on, every call and every letter gets one line: the date, +the organisation, the person's name, what they said, and any reference number +they gave you. + +This feels like busywork on day one. It is the single most useful thing you +will do. Recovery runs on being able to say "I reported this on the 4th, my +reference is X, and I was told Y" — often months later, to someone who has no +record of the previous conversation. + +## Step 2: Write down what you already know + +What made you realise? A charge you did not make, a letter about an account +you never opened, a rejected tax return, a debt collector, a denied +application, a breach notification. Note the dates and the amounts. + +You will be asked to repeat this story to a dozen organisations. Having it +written down once means it stays consistent, and consistency matters when +somebody is deciding whether to believe you. + +## Step 3: Know what identity theft is not your fault + +You did not cause this by clicking something. The overwhelming majority of +identity theft starts with data that a company lost — a breach, a stolen +laptop, a contractor's database. Reconstructing what you "should have done +differently" burns the energy you need for the next four phases. + +There is also a practical reason not to blame yourself: people who feel +foolish tend to under-report, and under-reporting is what costs money later. + +## Step 4: Understand the order, and why it is this order + +The rest of this guide runs: check the device, freeze the accounts, file the +official reports, dispute the damage, then monitor. + +Freezing comes before reporting because a freeze stops new damage while the +paperwork moves. Reporting comes before disputing because the official report +is what obliges credit bureaus and creditors to act — dispute letters sent +without one get form-letter replies. + +## Step 5: Decide what to do about the immediate money + +If money has actually left an account, that is the one thing that does not +wait for the sequence. Call the bank's fraud line — the number on the back of +the card, not one from a search result or an email — and say the words +"unauthorised transaction" and "fraud". + +Timing rules are real and short. In the US, unauthorised debit card charges +are capped at $50 if you report within two business days of noticing, and can +rise to $500 or the whole balance after that. Credit cards have stronger +protections than debit cards, which is the practical reason to use a credit +card online. diff --git a/guides/identity_theft_recovery/phase_1.md b/guides/identity_theft_recovery/phase_1.md new file mode 100644 index 0000000..6271227 --- /dev/null +++ b/guides/identity_theft_recovery/phase_1.md @@ -0,0 +1,60 @@ +--- +profile: identity_theft_recovery +phase: 1 +title: "Make Sure The Device Is Not The Leak" +automatable_steps: [1, 2, 3] +human_only_steps: [4, 5] +estimated_time: "45 minutes" +--- + +## Step 1: Scan for monitoring and malware on this device + +Run the profile's device checks: `stalkerware_scan`, `keylogger_indicators`, +`malware_scan_indicators` or `win_malware_indicators`, `suspicious_processes` +or `win_suspicious_processes`, and `process_scanner`. + +The point is narrow. You are about to type new passwords, account numbers, and +possibly a Social Security number into this machine. If something is recording +keystrokes, every step after this hands the thief a fresh copy. + +## Step 2: Check the browser, where credential theft usually lives + +Run `browser_extension_audit`, `browser_hijack_check`, and +`certificate_trust_audit`. + +An extension with permission to read every page can read your banking session +as easily as you can. A rogue root certificate lets whoever installed it read +traffic that the padlock says is encrypted. Both are quieter than malware and +both are commonly how account access outlives a password change. + +## Step 3: Check whether someone else is still logged in + +Run `remote_login_check` or `win_remote_access_audit`. + +Remote access left switched on is the most boring explanation for ongoing +fraud, and the most common one after a "tech support" phone call. + +## Step 4: If anything was found, do the recovery from a different device + +Do not clean the machine and immediately carry on. Borrow a device, use a +phone on mobile data, or use a library computer for the account changes, and +come back to cleaning this one afterwards. + +If the checks found something serious, the `digital_security_reset` profile +covers the cleanup, and a full operating-system reinstall is the only way to +be certain. + +## Step 5: Secure your email and phone before anything else + +Email and phone number are the recovery path for every other account, which +makes them the two things worth over-protecting: + +- New, unique password on the primary email account, and two-factor + authentication turned on — an authenticator app rather than SMS. +- Check the email account's forwarding rules and filters. A rule quietly + forwarding or deleting mail is how a thief keeps reading your alerts after + you have changed the password. It survives password changes; look for it + explicitly. +- Call your mobile carrier and add a port-out PIN or a SIM-swap lock to the + account. Without one, a thief who can convince a shop assistant to move your + number receives every SMS code you have. diff --git a/guides/identity_theft_recovery/phase_2.md b/guides/identity_theft_recovery/phase_2.md new file mode 100644 index 0000000..4e73f5a --- /dev/null +++ b/guides/identity_theft_recovery/phase_2.md @@ -0,0 +1,99 @@ +--- +profile: identity_theft_recovery +phase: 2 +title: "Freeze Everything (Stop New Damage)" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6, 7] +estimated_time: "2 hours" +--- + +## Step 1: Freeze your credit at all three major bureaus + +A credit freeze stops anyone — including you — from opening new credit in +your name until you lift it. It is free, it does not affect your credit score, +and it is the single most effective thing on this list. + +In the US, all three, because creditors do not all check the same one: + +- Equifax — equifax.com/personal/credit-report-services — 1-800-685-1111 +- Experian — experian.com/freeze — 1-888-397-3742 +- TransUnion — transunion.com/credit-freeze — 1-888-909-8872 + +Save the PIN or account login each one gives you into your password manager. +Losing it turns a two-minute unfreeze into a paperwork exercise later. + +A freeze is not the same as the "credit lock" a bureau will try to sell you. +The freeze is the one backed by federal law and required to be free. + +## Step 2: Freeze the three bureaus nobody mentions + +Fraud that gets refused at the big three often succeeds at these, because +utility companies, phone carriers, and banks check different databases: + +- Innovis (fourth credit bureau) — innovis.com — 1-800-540-2505 +- NCTUE (utility and telecom accounts) — nctue.com — 1-866-349-5355 +- ChexSystems (new bank accounts) — chexsystems.com — 1-800-428-9623 + +Opening a phone contract or a chequing account in your name is a normal next +move for a thief, and neither goes through the big three. + +## Step 3: Place a fraud alert + +A fraud alert tells lenders to take extra steps to verify identity before +granting credit. Placing one with any single bureau obliges it to notify the +other two. + +An initial alert lasts one year. Once you have the FTC identity theft report +from Phase 3, you can upgrade to an extended alert lasting seven years — which +is why this step is worth revisiting after the next phase. + +## Step 4: Call every bank and card issuer, not just the affected one + +For each institution: report the fraud, ask them to add a fraud flag to your +file, and ask what internal verification they can add — a passphrase, a +call-back requirement, or a note that in-branch identification is required. + +Ask each one directly: "Are there any accounts, cards, or applications on file +that I have not told you about?" That question surfaces accounts opened in +your name at a bank you already use, which no credit report search will show +you as quickly. + +## Step 5: Change passwords on financial and government accounts + +Bank, card issuers, payment apps, brokerage, tax authority, benefits, pension, +insurance. New unique password each, two-factor authentication on each, from a +device you cleared in Phase 1. + +Where the account offers it, change the security questions too. Mother's +maiden name and first school are exactly the details that get bought in bulk. + +## Step 6: Reclaim the mail + +Physical mail is still a live channel for identity theft. Check that no +change-of-address request has been filed in your name — in the US, the Postal +Service sends a confirmation letter to the old address when one is filed, so +look back through recent post for one you did not request. + +Sign up for the postal service's daily mail preview if it offers one, so you +can see what should be arriving. If mail has gone missing, report it to the +Postal Inspection Service at uspis.gov or 1-877-876-2455. + +## Step 7: Outside the US, use these equivalents + +The sequence in this guide holds everywhere; the institutions differ. + +- **UK** — report to Action Fraud (actionfraud.police.uk, 0300 123 2040) and + take out a Cifas Protective Registration (cifas.org.uk), which is the + closest equivalent to a freeze. Credit files are held by Experian, Equifax, + and TransUnion UK. +- **Canada** — Canadian Anti-Fraud Centre (antifraudcentre.ca, 1-888-495-8501), + then fraud alerts with Equifax Canada and TransUnion Canada. +- **Australia** — IDCARE (idcare.org) provides free case-managed support; also + report through ReportCyber and request a credit ban with Equifax, Experian, + and illion. +- **EU/EEA** — report to national police, and to your data protection + authority if a company's breach caused it; national banking ombudsmen handle + disputed transactions. + +Whatever the country: report to the police, freeze or flag the credit file, +and keep the reference numbers. Everything after that is the same work. diff --git a/guides/identity_theft_recovery/phase_3.md b/guides/identity_theft_recovery/phase_3.md new file mode 100644 index 0000000..2676330 --- /dev/null +++ b/guides/identity_theft_recovery/phase_3.md @@ -0,0 +1,105 @@ +--- +profile: identity_theft_recovery +phase: 3 +title: "Report It Officially" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6, 7] +estimated_time: "2-3 hours" +--- + +## Step 1: File the FTC report at IdentityTheft.gov + +This is the keystone step in the US, and it is free. IdentityTheft.gov walks +through what happened and produces two things: + +- An **FTC Identity Theft Report**, which is a legal document. It is what + entitles you to have fraudulent accounts blocked from your credit report, to + the seven-year extended fraud alert, and to demand that creditors stop + collecting on debts that are not yours. +- A personalised recovery plan with pre-filled dispute letters. + +Download the report and save several copies. You will be attaching it to +almost everything in Phase 4. + +## Step 2: File a police report + +Take your FTC report, photo identification, proof of address, and your log of +fraudulent accounts to the local police, or use the online reporting form if +your force has one. + +Some officers will tell you it is not worth filing because the thief is +untraceable. File anyway, and ask for the report number: several creditors +will not process a fraud claim without one, and some states issue an identity +theft passport or clearance letter that only exists off the back of a police +report. + +## Step 3: Protect the tax file + +Tax refund fraud is one of the most common uses of a stolen Social Security +number, and you often find out by having a legitimate return rejected as a +duplicate. + +In the US: + +- Get an **Identity Protection PIN** at irs.gov/ippin. It is a six-digit code, + reissued annually, without which no return can be filed under your number. + Anyone can opt in — you do not have to be a confirmed victim. +- If a return has already been filed in your name, file **Form 14039** + (Identity Theft Affidavit), and call the IRS Identity Protection Specialized + Unit at 1-800-908-4490. +- Contact your state tax authority separately. The federal filing does not + reach them. + +## Step 4: Report Social Security number misuse + +If the number itself has been used — for employment, benefits, or credit: + +- Report to the SSA Office of the Inspector General at oig.ssa.gov, or + 1-800-269-0271. +- Create an account at ssa.gov and check your earnings record for wages you + did not earn, which is the visible sign of someone working under your + number. +- Use E-Verify Self Lock (e-verify.gov) to block your number from being used + in employment verification. + +Getting a new Social Security number is possible but rarely advisable — a new +number with no history creates its own problems, and the old one stays linked. + +## Step 5: Report to the financial regulator + +In the US, file a complaint with the Consumer Financial Protection Bureau at +consumerfinance.gov/complaint for any bank, card issuer, credit bureau, or +debt collector that is not cooperating. Companies have to respond, usually +within 15 days, and it creates a paper trail that gets slow cases moving. + +Complaints to your state attorney general (naag.org lists them) are the +equivalent lever for businesses outside the CFPB's remit. + +## Step 6: Report the specific flavour of theft you are dealing with + +Each of these has its own channel, and skipping it leaves the damage in place: + +- **Phone number stolen (SIM swap or port-out)** — carrier fraud department + first, then the FCC at fcc.gov/complaints. +- **Medical identity theft** — call each provider and insurer, ask for an + accounting of disclosures, and request corrections to the medical record. + Wrong blood type or allergies in your file is a safety issue, not just a + billing one. Medicare fraud: 1-800-MEDICARE. +- **Criminal identity theft** (someone arrested using your name) — contact the + arresting agency and the court directly; ask about an identity theft + passport or clearance letter. +- **A child's identity** — the bureaus will create and immediately freeze a + minor's credit file on request, free of charge. Check for one; a child + should have no credit file at all. +- **A deceased relative's identity** — send a copy of the death certificate to + all three bureaus and ask for the file to be flagged "deceased — do not + issue credit". + +## Step 7: Update the log, and file everything in one place + +Add every reference number from this phase to the log: FTC report number, +police report number, IRS and SSA case numbers, CFPB complaint number. + +Keep one folder — paper or a single encrypted archive — with the FTC report, +the police report, and your log. Phase 4 is largely a matter of attaching +these same three documents to a dozen different disputes. diff --git a/guides/identity_theft_recovery/phase_4.md b/guides/identity_theft_recovery/phase_4.md new file mode 100644 index 0000000..73951d0 --- /dev/null +++ b/guides/identity_theft_recovery/phase_4.md @@ -0,0 +1,83 @@ +--- +profile: identity_theft_recovery +phase: 4 +title: "Dispute And Undo The Damage" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6] +estimated_time: "3 hours, then several weeks of follow-up" +--- + +## Step 1: Pull all three credit reports and mark up every line + +Get them free at annualcreditreport.com — the official US site, free weekly +for all three bureaus. Ignore anything that asks for a card number. + +Read every page and mark: accounts you did not open, addresses you have never +lived at, employers you have never worked for, and enquiries you did not +authorise. Addresses matter more than people expect — a thief's mailing +address on your file is how they receive the cards. + +Do this for all three separately. Fraud frequently appears on only one. + +## Step 2: Block the fraudulent accounts, do not merely dispute them + +There are two different processes and they get confused constantly: + +- A **dispute** asks the bureau to investigate. They have 30 days, and the + creditor can simply reassert the debt. +- An **identity theft block** (US, FCRA §605B) requires the bureau to remove + the information within four business days, once you give them your FTC + identity theft report. It is a stronger right and it is faster. + +Send the block request with a copy of the FTC report, proof of identity, and a +list of exactly which items are fraudulent. Use the letters IdentityTheft.gov +generated for you. + +## Step 3: Notify each creditor directly, in writing + +The bureau is not the creditor. Write to the fraud department of every company +where an account was opened, attaching the FTC report and the police report, +and asking them to: + +1. Close the account as fraudulent, effective from the date opened. +2. Confirm in writing that you are not liable. +3. Stop reporting it to the credit bureaus. +4. Provide the application records and transaction history — in the US you can + demand these under FCRA §609(e), and they are useful evidence of where the + thief operated. + +Send by certified mail with return receipt, or the local equivalent. Email is +fine as a courtesy copy but proof of delivery is what settles arguments. + +## Step 4: Deal with the debt collectors properly + +When a collector contacts you about a fraudulent debt, respond in writing +within 30 days: state that the debt is the result of identity theft, request +validation of the debt, and enclose the FTC report. + +Once notified in writing, a collector must stop collection activity until it +has verified the debt, and must tell whoever it is collecting for that the +debt is disputed as identity theft. Do not agree to a payment plan, do not +make a good-faith partial payment, and do not acknowledge the debt as yours — +any of those can restart a limitation period. + +## Step 5: Set a follow-up schedule and expect to repeat yourself + +Disputes go quiet. Put dates in the calendar now: + +- Day 5: confirm each block request was received. +- Day 30: check the credit reports again to confirm removal. +- Day 45: escalate anything still present to the CFPB. +- Day 90: pull all three reports again — fraudulent accounts sometimes + reappear when a creditor re-sells the debt to a new collector. + +Reappearing accounts are normal and do not mean you did it wrong. Each new +collector is a new party who has not seen your paperwork. + +## Step 6: Log every dispute with its deadline + +For each item: which bureau or creditor, what you sent, the date sent, the +delivery confirmation number, the legal deadline, and the outcome. + +When something goes wrong six months from now, this table is what turns "I +think I sent something" into a CFPB complaint that gets resolved in two weeks. diff --git a/guides/identity_theft_recovery/phase_5.md b/guides/identity_theft_recovery/phase_5.md new file mode 100644 index 0000000..6001dd0 --- /dev/null +++ b/guides/identity_theft_recovery/phase_5.md @@ -0,0 +1,86 @@ +--- +profile: identity_theft_recovery +phase: 5 +title: "Monitor, Rebuild, And Look After Yourself" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6] +estimated_time: "1 hour, then 20 minutes a month" +--- + +## Step 1: Upgrade to the seven-year extended fraud alert + +Now that you have the FTC identity theft report, ask one credit bureau for an +**extended fraud alert**. It lasts seven years, obliges lenders to contact you +directly before granting credit, and removes you from prescreened credit offer +lists for five years. The bureau you ask must notify the other two. + +Keep the credit freezes in place as well. The freeze blocks; the alert warns. +They do different jobs. + +## Step 2: Stop the prescreened offers that fuel the fraud + +Pre-approved credit offers arriving in the post are raw material for identity +theft. Opt out at optoutprescreen.com or 1-888-567-8688 — five years online, +permanently if you post the signed form. + +While you are at it, shred rather than bin anything with an account number on +it, and consider a locking mailbox if yours is accessible from the street. + +## Step 3: Set the monitoring rhythm + +- **Weekly for the first three months**: check bank and card transactions. +- **Monthly**: pull one credit report — rotate through the three bureaus, so + you see each one every quarter for free. +- **Annually**: renew the IRS Identity Protection PIN (it is reissued each + year), check your SSA earnings record, and review who has access to your + accounts. + +Turn on transaction alerts at every bank and card issuer — a text for every +transaction over a small threshold catches fraud in hours instead of at the +end of the statement period. + +## Step 4: Fix the structural weaknesses while you are here + +The recovery is also the opportunity: + +- Password manager, unique password everywhere, no exceptions. +- Authenticator app instead of SMS for two-factor, wherever it is offered. +- Answers to security questions that are not facts about your life. They are + just second passwords; store the made-up answers in the password manager. +- A separate email address used only for financial accounts, never given out + publicly or used for shopping. +- Check haveibeenpwned.com for which breaches your addresses appear in, so you + know which accounts to prioritise. + +## Step 5: Know what to expect over the next two years + +Identity theft has a long tail, and none of the following means you failed: + +- Fraudulent accounts reappearing after being removed, usually via a new debt + collector. +- New fraud attempts using the same stolen data, months apart, because the + data is resold repeatedly. +- Credit applications taking longer, or requiring a phone call, because of the + fraud alert. That is the alert working. +- Finding one more account you had not noticed. Go back to Phase 4 for that + one item; you do not restart the whole process. + +Keep the recovery log for at least seven years. It is the difference between +handling a recurrence in an afternoon and starting from scratch. + +## Step 6: Acknowledge what this cost you + +Identity theft recovery takes most people somewhere between a few weeks and +several months of intermittent, tedious, infuriating work, usually while being +treated with suspicion by organisations that should be helping. The emotional +weight of that is real, and it is not proportional to the amount of money +involved. + +Tell one person you trust what happened. If it is affecting your sleep or your +ability to function, that is a normal response to a violation, and worth +mentioning to a doctor or a counsellor. + +If you are in the US, the Identity Theft Resource Center (idtheftcenter.org, +1-888-400-5530) offers free case-managed help from real advisors, and is worth +calling when the paperwork stops making sense. In Australia, IDCARE +(idcare.org) does the same. Neither charges. diff --git a/guides/identity_theft_recovery/phase_6.md b/guides/identity_theft_recovery/phase_6.md new file mode 100644 index 0000000..2c18284 --- /dev/null +++ b/guides/identity_theft_recovery/phase_6.md @@ -0,0 +1,150 @@ +--- +profile: identity_theft_recovery +phase: 6 +title: "Resources, Tiplines, And Free Help" +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6, 7, 8] +estimated_time: "reference — use as needed" +--- + +## Step 1: Before you call anything, know how the fake helplines work + +Search results for "Equifax fraud number" or "IRS help line" are bought by +scam call centres, and the people who answer are convincing. Somebody already +in the middle of identity theft recovery is exactly who they are hoping to +reach. + +Two rules that make this safe: + +- Type the official domain into the address bar yourself, or use the number on + the back of your card or on a paper statement. Do not call a number from a + search advert, a text, an email, or a pop-up. +- No legitimate agency will ever ask you to pay by gift card, wire transfer, + cryptocurrency, or payment app. There is no fee to freeze credit, file an + FTC report, or get an IRS Identity Protection PIN. Anyone charging for those + is either reselling something free or stealing from you. + +Details below were accurate at the time of writing; verify on the official +site if something looks different. + +## Step 2: Free case-managed help from real people + +These will walk through your specific situation with you, at no cost. Call +them when the paperwork stops making sense — it is what they are for. + +- **Identity Theft Resource Center** (US) — idtheftcenter.org — + 1-888-400-5530, or text IDTHEFT to 88788. Free advisors who stay with your + case, not a one-off call. +- **AARP Fraud Watch Network Helpline** — 1-877-908-3360. Free and open to + anyone of any age, not only AARP members. +- **National Elder Fraud Hotline** (US Dept of Justice) — 1-833-372-8311. Case + managers for victims aged 60 and over. +- **IDCARE** (Australia and New Zealand) — idcare.org — 1800 595 160 (AU), + 0800 121 068 (NZ). Free case-managed identity support. +- **Access Now Digital Security Helpline** — accessnow.org/help. Free, 24/7, + in nine languages, for journalists, activists, and civil society. + +## Step 3: The official reporting channels + +- **IdentityTheft.gov** (FTC) — the report that unlocks your legal rights. + Phone: 1-877-438-4338, TTY 1-866-653-4261. +- **ReportFraud.ftc.gov** — for scams and fraud that are not identity theft. +- **FBI Internet Crime Complaint Center** — ic3.gov. +- **CFPB** (banks, card issuers, bureaus, collectors that will not cooperate) — + consumerfinance.gov/complaint — 1-855-411-2372. +- **IRS** — identity theft central at irs.gov/identity-theft-central, + Identity Protection PIN at irs.gov/ippin, Identity Protection Specialized + Unit 1-800-908-4490. +- **Social Security Administration** — fraud reporting oig.ssa.gov or + 1-800-269-0271; general 1-800-772-1213. +- **US Postal Inspection Service** (mail theft, forged change of address) — + uspis.gov — 1-877-876-2455. +- **Medicare fraud** — 1-800-633-4227; Senior Medicare Patrol at + smpresource.org for help reading the statements. +- **Your state attorney general** — directory at naag.org. + +## Step 4: The credit bureaus and the databases nobody mentions + +Freezes are free by law. Do all of them; creditors do not check the same one. + +- Equifax — equifax.com/personal/credit-report-services — 1-800-685-1111 +- Experian — experian.com/freeze — 1-888-397-3742 +- TransUnion — transunion.com/credit-freeze — 1-888-909-8872 +- Innovis (fourth bureau) — innovis.com — 1-800-540-2505 +- NCTUE (utility and phone accounts) — nctue.com — 1-866-349-5355 +- ChexSystems (new bank accounts) — chexsystems.com — 1-800-428-9623 +- LexisNexis and SageStream also hold consumer files and accept freezes + +Reports and opt-outs: + +- **annualcreditreport.com** — 1-877-322-8228 — the only federally authorised + free source. Free weekly from all three. +- **optoutprescreen.com** — 1-888-567-8688 — stops prescreened credit offers. +- **dmachoice.org** — reduces marketing mail. + +## Step 5: If the person who did this knows you + +Identity theft by a partner, ex, parent, or housemate is common, and it is +handled differently — the safety plan comes before the paperwork, because +cutting off access is what escalates. + +- **National Domestic Violence Hotline** (US) — 1-800-799-7233, TTY + 1-800-787-3224, or text START to 88788 — thehotline.org +- **NNEDV Safety Net** — techsafety.org — specialists in technology-facilitated + abuse, including financial abuse and coerced debt +- **Coalition Against Stalkerware** — stopstalkerware.org — international + service directory +- **SPARC** (stalking) — stalkingawareness.org +- **Operation Safe Escape** — safeescape.org — security help for people leaving + abusive situations +- **UK** — National Domestic Abuse Helpline 0808 2000 247; Refuge tech abuse + team at refugetechsafety.org +- **Australia** — 1800RESPECT, 1800 737 732 + +Coerced debt — accounts opened under threat or without meaningful consent — is +recognised by a growing number of states and by the CFPB. Say those words to +the advocate; it changes which route is available to you. + +## Step 6: Legal help, mostly free + +- **LawHelp.org** — free and low-cost legal aid by state +- **National Association of Consumer Advocates** — consumeradvocates.org — + lawyers who specialise in credit reporting and debt collection, many working + on contingency +- Your state or local **bar association referral service** +- Law school **consumer law clinics**, which often take identity theft cases + +Under the US Fair Credit Reporting Act, a consumer who wins a case can recover +legal fees from the other side, which is why many of these lawyers take cases +without charging up front. + +## Step 7: Outside the US, use these national channels + +- **UK** — Action Fraud, actionfraud.police.uk, 0300 123 2040. Cifas Protective + Registration, cifas.org.uk. Cyber incidents: ncsc.gov.uk/report. +- **Canada** — Canadian Anti-Fraud Centre, antifraudcentre.ca, 1-888-495-8501. + Equifax Canada 1-800-465-7166, TransUnion Canada 1-800-663-9980. +- **Australia** — IDCARE (above), ReportCyber at cyber.gov.au, Scamwatch. + Credit bans with Equifax, Experian, and illion. +- **New Zealand** — CERT NZ, Netsafe 0508 638 723, IDCARE NZ. +- **Ireland** — report to An Garda Síochána; the Central Bank handles + complaints about financial institutions. +- **EU/EEA** — national police for the theft, and your national data protection + authority if a company's breach caused it. Banking ombudsmen handle disputed + transactions. + +## Step 8: Keep learning, and check what is already exposed + +- **haveibeenpwned.com** — which breaches include your email addresses +- **EFF Surveillance Self-Defense** — ssd.eff.org — plain-language security + guides +- **Consumer Reports Security Planner** — + securityplanner.consumerreports.org — a personalised checklist, free +- **CISA** (US) — cisa.gov/report — 1-844-729-2472 — for compromise of systems + rather than personal identity +- **Citizen Lab** and **Amnesty International Security Lab** — for people who + may be targeted by state-grade or mercenary spyware + +Add the two or three numbers you actually used to your recovery log. In eight +months, when something reappears, the number you already know works is worth +more than another search. diff --git a/modules/bloatware/login_items/data/known_bloatware.json b/modules/bloatware/login_items/data/known_bloatware.json index a94fb5a..3acfb10 100644 --- a/modules/bloatware/login_items/data/known_bloatware.json +++ b/modules/bloatware/login_items/data/known_bloatware.json @@ -33,5 +33,45 @@ "name_pattern": "GoToMeeting", "name": "GoToMeeting", "description": "GoToMeeting background helper that launches at login even when not in a meeting." + }, + { + "name_pattern": "MacKeeper", + "name": "MacKeeper", + "description": "Scareware that invents system problems to sell a licence. It sets itself to start at login so it can reinstall itself after removal; remove the login item as well as the application." + }, + { + "name_pattern": "Advanced Mac Cleaner", + "name": "Advanced Mac Cleaner", + "description": "Fake cleaning utility that reports invented problems to sell a licence, and restarts itself at every login." + }, + { + "name_pattern": "Mac Auto Fixer", + "name": "Mac Auto Fixer", + "description": "Fake repair utility distributed by bundled installers; it starts at login to keep showing warnings." + }, + { + "name_pattern": "MacBooster", + "name": "MacBooster", + "description": "Fake performance optimiser that exaggerates system issues to sell upgrades." + }, + { + "name_pattern": "Genieo", + "name": "Genieo", + "description": "Adware that hijacks the browser homepage and search engine and restores itself at login." + }, + { + "name_pattern": "SearchMine", + "name": "SearchMine", + "description": "Browser hijacker that redirects searches and reinstates itself after being removed." + }, + { + "name_pattern": "TeamViewer", + "name": "TeamViewer", + "description": "Remote control software set to start at login. That is expected if it is deliberately used for remote support, and a serious problem if nobody knows why it is there — it lets whoever holds its credentials watch and control this Mac." + }, + { + "name_pattern": "AnyDesk", + "name": "AnyDesk", + "description": "Remote control software set to start at login. It is frequently left behind after 'tech support' phone scams; if you do not know why it is installed, treat it as unauthorised remote access." } ] diff --git a/modules/bloatware/process_scanner/__init__.py b/modules/bloatware/process_scanner/__init__.py index 436d25f..884d727 100644 --- a/modules/bloatware/process_scanner/__init__.py +++ b/modules/bloatware/process_scanner/__init__.py @@ -17,7 +17,11 @@ from rescue.runtime import content_file DATA_FILE = content_file("modules/bloatware/process_scanner/data/known_bloatware.json") -CRITICAL_CATEGORIES = {"scareware"} +# Categories where the software's own purpose is the harm: scareware extracts +# money through invented problems, and a miner spends the owner's electricity +# on someone else's behalf. Adware and bundled programs are a nuisance and are +# reported as warnings. +CRITICAL_CATEGORIES = {"scareware", "cryptominer"} class Module(ModuleBase): diff --git a/modules/bloatware/process_scanner/data/known_bloatware.json b/modules/bloatware/process_scanner/data/known_bloatware.json index 53bcbfe..52e5a66 100644 --- a/modules/bloatware/process_scanner/data/known_bloatware.json +++ b/modules/bloatware/process_scanner/data/known_bloatware.json @@ -5,5 +5,36 @@ {"process_pattern": "Advanced Mac Cleaner", "name": "Advanced Mac Cleaner", "category": "scareware", "description": "Fake system cleaner that pressures users into paying for a license to fix invented problems."}, {"process_pattern": "SearchProtect", "name": "Conduit SearchProtect", "category": "adware", "description": "Browser hijacker that locks in a hijacked search engine and homepage."}, {"process_pattern": "MacBooster", "name": "MacBooster", "category": "scareware", "description": "Fake performance-optimization utility that exaggerates system issues to sell upgrades."}, - {"process_pattern": "Genio", "name": "Genio", "category": "adware", "description": "Adware variant that injects ads into browser sessions."} + {"process_pattern": "Genio", "name": "Genio", "category": "adware", "description": "Adware variant that injects ads into browser sessions."}, + {"process_pattern": "Pirrit", "name": "Pirrit", "category": "adware", "description": "macOS adware family that injects advertising into web pages and installs a persistent background agent to reinstall itself."}, + {"process_pattern": "Bundlore", "name": "Bundlore", "category": "adware", "description": "macOS adware dropper distributed inside fake Flash Player and video-codec installers; it installs further adware and browser extensions."}, + {"process_pattern": "AdLoad", "name": "AdLoad", "category": "adware", "description": "macOS adware that installs a persistent agent and a local network proxy so it can insert advertising into browsing sessions."}, + {"process_pattern": "Shlayer", "name": "Shlayer", "category": "adware", "description": "Widespread macOS malware family that arrives as a fake Flash update and installs whichever adware pays its operators the most."}, + {"process_pattern": "Crossrider", "name": "Crossrider", "category": "adware", "description": "Adware development platform used to build browser hijackers that change the homepage and search engine."}, + {"process_pattern": "VSearch", "name": "VSearch", "category": "adware", "description": "macOS adware that redirects searches and installs a launch daemon so it survives removal."}, + {"process_pattern": "Mac Auto Fixer", "name": "Mac Auto Fixer", "category": "scareware", "description": "Fake repair utility that reports invented errors to sell a licence."}, + {"process_pattern": "Mac Adware Cleaner", "name": "Mac Adware Cleaner", "category": "scareware", "description": "Fake cleaning utility that is itself adware."}, + {"process_pattern": "Segurazo", "name": "Segurazo / SAntivirus", "category": "scareware", "description": "Rogue antivirus that installs without clear consent, reports invented threats, and resists uninstallation."}, + {"process_pattern": "Reimage", "name": "Reimage Repair", "category": "scareware", "description": "System repair product advertised through fake error pages and support-scam popups."}, + {"process_pattern": "Restoro", "name": "Restoro", "category": "scareware", "description": "PC repair product distributed through scare-tactic advertising and fake virus warnings."}, + {"process_pattern": "PC Accelerate", "name": "PC Accelerate", "category": "scareware", "description": "Bundled optimiser that reports invented problems to sell a licence."}, + {"process_pattern": "OneSafe PC Cleaner", "name": "OneSafe PC Cleaner", "category": "scareware", "description": "Fake system cleaner distributed by bundled installers."}, + {"process_pattern": "DriverPack", "name": "DriverPack Solution", "category": "pup", "description": "Driver installer that bundles unrelated software and changes browser settings."}, + {"process_pattern": "Wajam", "name": "Wajam", "category": "adware", "description": "Ad-injecting proxy that installs its own root certificate so it can modify encrypted web traffic."}, + {"process_pattern": "OpenCandy", "name": "OpenCandy", "category": "pup", "description": "Installer bundling framework that adds unrelated software during setup."}, + {"process_pattern": "InstallCore", "name": "InstallCore", "category": "pup", "description": "Installer bundling framework used to distribute adware and browser hijackers."}, + {"process_pattern": "Mindspark", "name": "Mindspark toolbars", "category": "adware", "description": "Toolbar family (MyWebSearch and relatives) that hijacks search and new-tab pages."}, + {"process_pattern": "MyWebSearch", "name": "MyWebSearch", "category": "adware", "description": "Search toolbar that replaces the browser's default search provider."}, + {"process_pattern": "Trovi", "name": "Trovi Search", "category": "adware", "description": "Browser hijacker that redirects searches through its own network."}, + {"process_pattern": "Vosteran", "name": "Vosteran", "category": "adware", "description": "Browser hijacker that locks the homepage and search engine."}, + {"process_pattern": "Delta Search", "name": "Delta Search", "category": "adware", "description": "Search hijacker installed alongside free software."}, + {"process_pattern": "WebDiscover", "name": "WebDiscover Browser", "category": "adware", "description": "Bundled browser that installs itself as the default and displays a persistent search bar."}, + {"process_pattern": "SearchMine", "name": "SearchMine", "category": "adware", "description": "macOS browser hijacker that redirects searches and reinstalls itself from a launch agent."}, + {"process_pattern": "Spigot", "name": "Spigot", "category": "adware", "description": "Adware family that installs browser extensions and changes search settings."}, + {"process_pattern": "Yontoo", "name": "Yontoo", "category": "adware", "description": "Ad-injection browser plugin installed by bundled setup programs."}, + {"process_pattern": "WildTangent", "name": "WildTangent Games", "category": "pup", "description": "Pre-installed game service that runs background updaters and displays advertising."}, + {"process_pattern": "xmrig", "name": "XMRig cryptocurrency miner", "category": "cryptominer", "description": "Cryptocurrency miner that spends this machine's processor time earning currency for whoever installed it. On a computer nobody deliberately set up for mining, it is stealing electricity and hardware life."}, + {"process_pattern": "mshelper", "name": "mshelper miner", "category": "cryptominer", "description": "macOS mining payload installed by fake Flash updates; pins a processor core at 100% and drains the battery."}, + {"process_pattern": "kdevtmpfsi", "name": "kdevtmpfsi miner", "category": "cryptominer", "description": "Linux cryptocurrency miner dropped by the Kinsing malware. The name imitates a kernel thread, but real kernel threads never run from /tmp."}, + {"process_pattern": "minergate", "name": "MinerGate", "category": "cryptominer", "description": "Mining client frequently bundled into cracked software and game modifications."} ] diff --git a/modules/bloatware/startup_auditor/data/known_bloatware.json b/modules/bloatware/startup_auditor/data/known_bloatware.json index 5d408bc..5dcbf0a 100644 --- a/modules/bloatware/startup_auditor/data/known_bloatware.json +++ b/modules/bloatware/startup_auditor/data/known_bloatware.json @@ -33,5 +33,60 @@ "label_pattern": "com.spotify.webhelper", "name": "Spotify Helper", "description": "Spotify background helper that keeps the app ready to launch instantly; disable if you don't need instant startup." + }, + { + "label_pattern": "com.zeobit.MacKeeper", + "name": "MacKeeper agent", + "description": "MacKeeper's background agent. MacKeeper is scareware: it invents system problems to sell a licence, and its agent reinstalls the app after it is dragged to the Trash. Remove the agent as well as the application." + }, + { + "label_pattern": "com.mackeeper", + "name": "MacKeeper helper", + "description": "MacKeeper background helper that restarts the scareware application after removal." + }, + { + "label_pattern": "com.pcv.hlpramc", + "name": "Advanced Mac Cleaner agent", + "description": "Persistence agent for Advanced Mac Cleaner, a fake cleaning utility that reports invented problems to sell a licence." + }, + { + "label_pattern": "com.genieoinnovation", + "name": "Genieo agent", + "description": "Genieo adware agent. It hijacks the browser's homepage and search engine and reinstalls itself at every login." + }, + { + "label_pattern": "com.genieo", + "name": "Genieo helper", + "description": "Genieo adware helper that restores the hijacked search settings after they are changed back." + }, + { + "label_pattern": "com.searchmine", + "name": "SearchMine agent", + "description": "Browser hijacker agent that redirects searches through SearchMine and reinstates itself after removal." + }, + { + "label_pattern": "com.spigot", + "name": "Spigot adware agent", + "description": "Spigot adware agent that installs browser extensions and changes search settings." + }, + { + "label_pattern": "com.vsearch", + "name": "VSearch agent", + "description": "VSearch adware agent that redirects searches and reinstalls its payload." + }, + { + "label_pattern": "com.pirrit", + "name": "Pirrit adware agent", + "description": "Pirrit adware agent. Pirrit injects advertising into web pages and keeps a background agent specifically so it can rebuild itself." + }, + { + "label_pattern": "com.adobe.fpsaud", + "name": "Adobe Flash Player update service", + "description": "Flash Player's update service. Flash reached end of life in 2020 and is no longer supported, so an agent still running under this name is either a leftover or adware impersonating Flash — fake Flash updaters are the most common macOS adware delivery method." + }, + { + "label_pattern": "com.avast", + "name": "Avast background agent", + "description": "Avast background service. It is functional antivirus software, but it also runs several always-on agents and has a history of monetising browsing data; keep it only if it is deliberately in use." } ] diff --git a/modules/bloatware/win_bloatware/data/known_bloatware.json b/modules/bloatware/win_bloatware/data/known_bloatware.json index 9ef0997..7e4ce8f 100644 --- a/modules/bloatware/win_bloatware/data/known_bloatware.json +++ b/modules/bloatware/win_bloatware/data/known_bloatware.json @@ -117,5 +117,82 @@ "app_pattern": "feedback", "description": "Windows Feedback Hub for sending system telemetry.", "estimated_resource_savings": "150 MB disk space" + }, + { + "name": "McAfee LiveSafe trial", + "publisher_pattern": "McAfee", + "app_pattern": "mcafee", + "description": "Pre-installed antivirus trial. Once the trial expires it stops updating but keeps Microsoft Defender switched off, which leaves the machine less protected than having no third-party antivirus at all. Uninstall it with McAfee's own removal tool, then confirm Defender turned itself back on.", + "estimated_resource_savings": "1 GB disk space, and Microsoft Defender re-enables itself" + }, + { + "name": "Norton Security trial", + "publisher_pattern": "NortonLifeLock", + "app_pattern": "norton", + "description": "Pre-installed antivirus trial that disables Microsoft Defender while it is present and stops receiving updates when the trial ends. Recent versions also ship a cryptocurrency miner feature, which is opt-in but installed regardless.", + "estimated_resource_savings": "1 GB disk space, and Microsoft Defender re-enables itself" + }, + { + "name": "WildTangent Games", + "publisher_pattern": "WildTangent", + "app_pattern": "wildtangent", + "description": "Pre-installed game service that runs a background updater and shows advertising.", + "estimated_resource_savings": "500 MB disk space, one background process" + }, + { + "name": "Booking.com", + "publisher_pattern": "Booking.com", + "app_pattern": "booking", + "description": "Pre-installed advertising app placed on many consumer laptops; it is a shortcut to a website.", + "estimated_resource_savings": "50 MB disk space" + }, + { + "name": "Candy Crush Friends Saga", + "publisher_pattern": "king", + "app_pattern": "candycrushfriends", + "description": "Additional pre-installed King game variant.", + "estimated_resource_savings": "400 MB disk space" + }, + { + "name": "Cooking Fever", + "publisher_pattern": "Nordcurrent", + "app_pattern": "cookingfever", + "description": "Pre-installed mobile game.", + "estimated_resource_savings": "300 MB disk space" + }, + { + "name": "Hidden City", + "publisher_pattern": "G5 Entertainment", + "app_pattern": "hiddencity", + "description": "Pre-installed mobile game.", + "estimated_resource_savings": "400 MB disk space" + }, + { + "name": "Dolby Access", + "publisher_pattern": "Dolby", + "app_pattern": "dolbyaccess", + "description": "Trial app for Dolby Atmos; the feature it unlocks is a paid add-on.", + "estimated_resource_savings": "200 MB disk space" + }, + { + "name": "PC App Store", + "publisher_pattern": "PC App Store", + "app_pattern": "pcappstore", + "description": "Third-party software store bundled by installers; it installs further software and advertising without clear consent.", + "estimated_resource_savings": "300 MB disk space, one background process" + }, + { + "name": "Driver Booster", + "publisher_pattern": "IObit", + "app_pattern": "driverbooster", + "description": "Driver updater that reports invented out-of-date drivers to sell an upgrade, and can install drivers Windows Update would not. Windows Update already handles drivers on supported systems.", + "estimated_resource_savings": "250 MB disk space, one background process" + }, + { + "name": "Advanced SystemCare", + "publisher_pattern": "IObit", + "app_pattern": "advancedsystemcare", + "description": "System optimiser that reports large numbers of low-impact issues to prompt an upgrade purchase, and installs several background services.", + "estimated_resource_savings": "400 MB disk space, several background processes" } ] diff --git a/modules/integrity/font_issues/__init__.py b/modules/integrity/font_issues/__init__.py index 15c8e82..6ee77bd 100644 --- a/modules/integrity/font_issues/__init__.py +++ b/modules/integrity/font_issues/__init__.py @@ -14,6 +14,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_file_nofollow FONT_COUNT_WARNING_THRESHOLD = 500 FONT_FOLDER_SIZE_WARNING_THRESHOLD = 1024 * 1024 * 1024 # 1GB @@ -316,7 +317,7 @@ def _get_directory_size(self, path: Path) -> int: return path.stat().st_size for item in path.rglob("*"): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): total_size += item.stat().st_size except (OSError, PermissionError): continue diff --git a/modules/integrity/icloud_status/__init__.py b/modules/integrity/icloud_status/__init__.py index 3808678..8f3e423 100644 --- a/modules/integrity/icloud_status/__init__.py +++ b/modules/integrity/icloud_status/__init__.py @@ -14,6 +14,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_file_nofollow class Module(ModuleBase): @@ -310,7 +311,7 @@ def _get_dir_size(self, path: Path) -> int: total = 0 try: for entry in path.rglob("*"): - if entry.is_file(follow_symlinks=False): + if is_file_nofollow(entry): try: total += entry.stat().st_size except (OSError, ValueError): diff --git a/modules/integrity/icloud_storage/__init__.py b/modules/integrity/icloud_storage/__init__.py index 0468250..eed1ad5 100644 --- a/modules/integrity/icloud_storage/__init__.py +++ b/modules/integrity/icloud_storage/__init__.py @@ -14,6 +14,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_file_nofollow class Module(ModuleBase): @@ -455,7 +456,7 @@ def _find_large_files_in_icloud(self, size_threshold: int = 100 * 1024 * 1024) - icloud_path = Path.home() / "Library" / "Mobile Documents" / "com~apple~CloudDocs" if icloud_path.exists() and icloud_path.is_dir(): for entry in icloud_path.rglob("*"): - if entry.is_file(follow_symlinks=False): + if is_file_nofollow(entry): try: if entry.stat().st_size > size_threshold: # Store just the relative path for readability @@ -473,7 +474,7 @@ def _get_dir_size(self, path: Path) -> int: total = 0 try: for entry in path.rglob("*"): - if entry.is_file(follow_symlinks=False): + if is_file_nofollow(entry): try: total += entry.stat().st_size except (OSError, ValueError): diff --git a/modules/integrity/mail_config/__init__.py b/modules/integrity/mail_config/__init__.py index 147de5a..fd3eac2 100644 --- a/modules/integrity/mail_config/__init__.py +++ b/modules/integrity/mail_config/__init__.py @@ -13,6 +13,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_file_nofollow class Module(ModuleBase): @@ -322,7 +323,7 @@ def _get_dir_size(self, path: Path) -> int: total = 0 try: for entry in path.rglob("*"): - if entry.is_file(follow_symlinks=False): + if is_file_nofollow(entry): try: total += entry.stat().st_size except (OSError, ValueError): diff --git a/modules/integrity/photos_library_check/__init__.py b/modules/integrity/photos_library_check/__init__.py index 9b94af8..f2209b8 100644 --- a/modules/integrity/photos_library_check/__init__.py +++ b/modules/integrity/photos_library_check/__init__.py @@ -13,6 +13,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_file_nofollow class Module(ModuleBase): @@ -295,7 +296,7 @@ def _get_dir_size(self, path: Path) -> int: total = 0 try: for entry in path.rglob("*"): - if entry.is_file(follow_symlinks=False): + if is_file_nofollow(entry): try: total += entry.stat().st_size except (OSError, ValueError): 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/performance/browser_cache_cleanup/__init__.py b/modules/performance/browser_cache_cleanup/__init__.py index 7bcf59e..13305d5 100644 --- a/modules/performance/browser_cache_cleanup/__init__.py +++ b/modules/performance/browser_cache_cleanup/__init__.py @@ -12,6 +12,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_file_nofollow # Thresholds SINGLE_BROWSER_WARNING = 2 * 1024 * 1024 * 1024 # 2 GB @@ -181,7 +182,7 @@ def _get_directory_size(self, path: Path) -> int: try: for item in path.rglob("*"): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): total_size += item.stat().st_size except (OSError, PermissionError): continue diff --git a/modules/performance/font_cache/__init__.py b/modules/performance/font_cache/__init__.py index e0a5c4e..d0771dc 100644 --- a/modules/performance/font_cache/__init__.py +++ b/modules/performance/font_cache/__init__.py @@ -13,6 +13,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_file_nofollow FONT_COUNT_WARNING_THRESHOLD = 500 @@ -224,7 +225,7 @@ def _get_directory_size(self, path: Path) -> int: return path.stat().st_size for item in path.rglob("*"): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): total_size += item.stat().st_size except (OSError, PermissionError): continue diff --git a/modules/performance/font_cache_repair/__init__.py b/modules/performance/font_cache_repair/__init__.py index 72f771b..8a159cb 100644 --- a/modules/performance/font_cache_repair/__init__.py +++ b/modules/performance/font_cache_repair/__init__.py @@ -13,6 +13,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_file_nofollow # Thresholds FONT_CACHE_SIZE_WARNING = 500 * 1024 * 1024 # 500 MB @@ -358,7 +359,7 @@ def _get_directory_size(self, path: Path) -> int: return path.stat().st_size for item in path.rglob("*"): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): total_size += item.stat().st_size except (OSError, PermissionError): continue diff --git a/modules/performance/large_files_finder/__init__.py b/modules/performance/large_files_finder/__init__.py index a85dd08..13ec5b8 100644 --- a/modules/performance/large_files_finder/__init__.py +++ b/modules/performance/large_files_finder/__init__.py @@ -15,6 +15,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_file_nofollow # Thresholds LARGE_FILE_SIZE = 1024 * 1024 * 1024 # 1 GB @@ -305,7 +306,7 @@ def _find_old_large_downloads(self, downloads_dir: Path) -> list[dict]: try: for item in downloads_dir.iterdir(): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): size = item.stat().st_size mtime = datetime.fromtimestamp(item.stat().st_mtime) @@ -331,7 +332,7 @@ def _find_large_desktop_files(self, desktop_dir: Path) -> list[dict]: try: for item in desktop_dir.iterdir(): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): size = item.stat().st_size if size > LARGE_FILE_SIZE: files.append({ diff --git a/modules/performance/library_cache_cleanup/__init__.py b/modules/performance/library_cache_cleanup/__init__.py index 2f671e8..b3b86e8 100644 --- a/modules/performance/library_cache_cleanup/__init__.py +++ b/modules/performance/library_cache_cleanup/__init__.py @@ -12,6 +12,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_dir_nofollow, is_file_nofollow # Thresholds TOTAL_CACHE_WARNING = 10 * 1024 * 1024 * 1024 # 10 GB @@ -266,7 +267,7 @@ def _scan_caches(self, caches_dir: Path) -> dict[str, int]: try: for item in caches_dir.iterdir(): try: - if item.is_dir(follow_symlinks=False): + if is_dir_nofollow(item): dir_size = self._get_directory_size(item) if dir_size > 0: cache_sizes[item.name] = dir_size @@ -303,7 +304,7 @@ def _get_directory_size(self, path: Path) -> int: try: for item in path.rglob("*"): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): total_size += item.stat().st_size except (OSError, PermissionError): continue diff --git a/modules/performance/mail_attachment_cleanup/__init__.py b/modules/performance/mail_attachment_cleanup/__init__.py index d8b7f34..32a419a 100644 --- a/modules/performance/mail_attachment_cleanup/__init__.py +++ b/modules/performance/mail_attachment_cleanup/__init__.py @@ -12,6 +12,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_file_nofollow # Thresholds MAIL_DATA_WARNING = 10 * 1024 * 1024 * 1024 # 10 GB @@ -166,7 +167,7 @@ def _get_directory_size(self, path: Path) -> int: try: for item in path.rglob("*"): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): total_size += item.stat().st_size except (OSError, PermissionError): continue diff --git a/modules/performance/notification_center_check/__init__.py b/modules/performance/notification_center_check/__init__.py index 1e730a4..d193bf1 100644 --- a/modules/performance/notification_center_check/__init__.py +++ b/modules/performance/notification_center_check/__init__.py @@ -41,6 +41,22 @@ class Module(ModuleBase): depends_on = [] estimated_duration = "3s" + # Filesystem locations this module reads, as attributes so a test can point + # them at a fixture instead of the running user's real home directory. + # None means "derive from Path.home() at call time". + ncprefs_path: Path | None = None + notification_center_dir: Path | None = None + + def _ncprefs_path(self) -> Path: + if self.ncprefs_path is not None: + return Path(self.ncprefs_path) + return Path.home() / "Library" / "Preferences" / "com.apple.ncprefs.plist" + + def _notification_center_dir(self) -> Path: + if self.notification_center_dir is not None: + return Path(self.notification_center_dir) + return Path.home() / "Library" / "Application Support" / "NotificationCenter" + def check(self, profile: SystemProfile) -> CheckResult: findings = [] @@ -185,7 +201,7 @@ def fix(self, findings: CheckResult, mode: Mode) -> FixResult: def _check_database_size(self) -> Finding | None: """Check notification database size at ~/Library/Application Support/NotificationCenter/""" try: - nc_path = Path.home() / "Library" / "Application Support" / "NotificationCenter" + nc_path = self._notification_center_dir() if not nc_path.exists(): return None @@ -223,7 +239,7 @@ def _get_notification_apps(self) -> dict | None: """Get notification app settings from defaults.""" try: # Read the notification preferences plist - prefs_path = Path.home() / "Library" / "Preferences" / "com.apple.ncprefs.plist" + prefs_path = self._ncprefs_path() if not prefs_path.exists(): return None diff --git a/modules/performance/storage_cleanup/__init__.py b/modules/performance/storage_cleanup/__init__.py index ceab53a..89cb5db 100644 --- a/modules/performance/storage_cleanup/__init__.py +++ b/modules/performance/storage_cleanup/__init__.py @@ -13,6 +13,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_dir_nofollow, is_file_nofollow # Thresholds OLD_FILES_DAYS = 90 @@ -272,7 +273,7 @@ def _scan_old_downloads(self, downloads_dir: Path) -> dict: try: for item in downloads_dir.iterdir(): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): mtime = datetime.fromtimestamp(item.stat().st_mtime) if mtime < cutoff: total_size += item.stat().st_size @@ -295,7 +296,7 @@ def _scan_large_caches(self, caches_dir: Path) -> dict: try: for item in caches_dir.iterdir(): try: - if item.is_dir(follow_symlinks=False): + if is_dir_nofollow(item): dir_size = self._get_directory_size(item) if dir_size > LARGE_CACHE_SIZE: total_size += dir_size @@ -316,9 +317,9 @@ def _get_trash_size(self, trash_dir: Path) -> int: try: for item in trash_dir.iterdir(): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): total_size += item.stat().st_size - elif item.is_dir(follow_symlinks=False): + elif is_dir_nofollow(item): total_size += self._get_directory_size(item) except (OSError, PermissionError): continue @@ -338,7 +339,7 @@ def _scan_dmg_files(self, downloads_dir: Path) -> dict: try: for item in downloads_dir.iterdir(): try: - if item.is_file(follow_symlinks=False) and item.suffix.lower() == ".dmg": + if is_file_nofollow(item) and item.suffix.lower() == ".dmg": total_size += item.stat().st_size count += 1 except (OSError, PermissionError): @@ -359,7 +360,7 @@ def _scan_app_support(self, app_support_dir: Path) -> dict: try: for item in app_support_dir.iterdir(): try: - if item.is_dir(follow_symlinks=False): + if is_dir_nofollow(item): # Simple heuristic: count directories (can't easily detect uninstalled apps) dir_size = self._get_directory_size(item) total_size += dir_size @@ -380,7 +381,7 @@ def _get_directory_size(self, path: Path) -> int: try: for item in path.rglob("*"): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): total_size += item.stat().st_size except (OSError, PermissionError): continue diff --git a/modules/performance/temp_file_scanner/__init__.py b/modules/performance/temp_file_scanner/__init__.py index f1b1c73..a7be923 100644 --- a/modules/performance/temp_file_scanner/__init__.py +++ b/modules/performance/temp_file_scanner/__init__.py @@ -13,6 +13,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_file_nofollow class Module(ModuleBase): @@ -268,7 +269,7 @@ def _get_directory_size(self, path: Path) -> int: try: for item in path.rglob("*"): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): total_size += item.stat().st_size except (OSError, PermissionError): # Skip files we can't access @@ -290,7 +291,7 @@ def _count_old_files(self, path: Path, days: int = 30) -> int: try: for item in path.rglob("*"): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): mtime = datetime.fromtimestamp(item.stat().st_mtime) if mtime < cutoff: count += 1 diff --git a/modules/performance/trash_cleanup/__init__.py b/modules/performance/trash_cleanup/__init__.py index c97613b..e1ab090 100644 --- a/modules/performance/trash_cleanup/__init__.py +++ b/modules/performance/trash_cleanup/__init__.py @@ -12,6 +12,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_dir_nofollow, is_file_nofollow # Thresholds TRASH_SIZE_WARNING = 5 * 1024 * 1024 * 1024 # 5 GB @@ -170,10 +171,10 @@ def _scan_trash(self, trash_dir: Path) -> dict: try: for item in trash_dir.rglob("*"): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): total_size += item.stat().st_size item_count += 1 - elif item.is_dir(follow_symlinks=False): + elif is_dir_nofollow(item): item_count += 1 except (OSError, PermissionError): continue @@ -199,10 +200,10 @@ def _scan_external_trash(self) -> dict: if trashes_dir.exists(): for trash_item in trashes_dir.rglob("*"): try: - if trash_item.is_file(follow_symlinks=False): + if is_file_nofollow(trash_item): total_size += trash_item.stat().st_size total_count += 1 - elif trash_item.is_dir(follow_symlinks=False): + elif is_dir_nofollow(trash_item): # Don't double-count directories in the count pass except (OSError, PermissionError): @@ -223,7 +224,7 @@ def _get_directory_size(self, path: Path) -> int: try: for item in path.rglob("*"): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): total_size += item.stat().st_size except (OSError, PermissionError): continue diff --git a/modules/performance/user_profile_size/__init__.py b/modules/performance/user_profile_size/__init__.py index 4a647db..b19d8c8 100644 --- a/modules/performance/user_profile_size/__init__.py +++ b/modules/performance/user_profile_size/__init__.py @@ -14,6 +14,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_dir_nofollow # Thresholds USER_DIR_WARNING_THRESHOLD = 50 * 1024**3 # 50 GB @@ -43,11 +44,17 @@ class Module(ModuleBase): depends_on = [] estimated_duration = "10s" + # Traversal roots, as attributes so a test can point them at a fixture tree + # instead of the real filesystem. Overriding these is the only supported way + # to exercise check() off a real macOS box. + users_root = Path("/Users") + home_root: Path | None = None # None => Path.home() + def check(self, profile: SystemProfile) -> CheckResult: findings = [] # Get all user directories - users_dir = Path("/Users") + users_dir = Path(self.users_root) user_dirs = self._get_user_directories(users_dir) if not user_dirs: @@ -70,7 +77,7 @@ def check(self, profile: SystemProfile) -> CheckResult: continue # Check Library directory bloat for current user - current_user = Path.home() + current_user = Path(self.home_root) if self.home_root else Path.home() library_path = current_user / "Library" library_size = 0 library_bloat = False @@ -255,7 +262,7 @@ def _get_user_directories(self, users_dir: Path) -> list[Path]: user_dirs = [] try: for item in users_dir.iterdir(): - if not item.is_dir(follow_symlinks=False): + if not is_dir_nofollow(item): continue if item.name in SKIP_DIRS: continue diff --git a/modules/performance/xcode_cleanup/__init__.py b/modules/performance/xcode_cleanup/__init__.py index 1d3dfe3..5cf7d26 100644 --- a/modules/performance/xcode_cleanup/__init__.py +++ b/modules/performance/xcode_cleanup/__init__.py @@ -12,6 +12,7 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.fsbounds import is_file_nofollow # Thresholds DERIVED_DATA_WARNING = 5 * 1024 * 1024 * 1024 # 5 GB @@ -280,7 +281,7 @@ def _get_directory_size(self, path: Path) -> int: try: for item in path.rglob("*"): try: - if item.is_file(follow_symlinks=False): + if is_file_nofollow(item): total_size += item.stat().st_size except (OSError, PermissionError): continue diff --git a/modules/security/appleid_security_check/__init__.py b/modules/security/appleid_security_check/__init__.py index 893fc22..33bbf36 100644 --- a/modules/security/appleid_security_check/__init__.py +++ b/modules/security/appleid_security_check/__init__.py @@ -25,6 +25,16 @@ class Module(ModuleBase): depends_on = [] estimated_duration = "5s" + # The one file this module opens directly rather than reading through + # `defaults`. An attribute so a test can supply a real fixture plist: + # patching plistlib.load is not enough, because open() still has to succeed. + mobileme_plist_path: Path | None = None + + def _mobileme_plist_path(self) -> Path: + if self.mobileme_plist_path is not None: + return Path(self.mobileme_plist_path) + return Path.home() / "Library/Preferences/MobileMeAccounts.plist" + emits_codes = [ "security.appleid_security_check.appleid_signin", "security.appleid_security_check.icloud_keychain", @@ -219,7 +229,7 @@ def fix(self, findings: CheckResult, mode: Mode) -> FixResult: def _check_appleid_signin(self) -> bool: """Check if signed in to Apple ID via MobileMeAccounts.plist.""" try: - plist_path = Path.home() / "Library/Preferences/MobileMeAccounts.plist" + plist_path = self._mobileme_plist_path() if not plist_path.exists(): return False 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/code_signature_audit/__init__.py b/modules/security/code_signature_audit/__init__.py new file mode 100644 index 0000000..38b9adb --- /dev/null +++ b/modules/security/code_signature_audit/__init__.py @@ -0,0 +1,463 @@ +"""Check whether installed applications are actually signed by who they claim. + +docs/ROADMAP.md P2, "Trust and reputation verification", opens with the reason +this exists: *file-name and keyword checks create false positives*. Most of the +detection in this toolkit matches names and paths against lists, which is cheap +and catches known-bad, but says nothing about whether a given binary is what it +claims to be. Code signing does. + +The severities here are chosen to be defensible rather than alarming: + +- CRITICAL only for a signature that is present and *broken* — the binary has + been modified since it was signed. That is tampering, not a configuration + choice, and there is no innocent reading of it. +- WARNING for unsigned or ad-hoc-signed software in a system-wide location, + where anyone can drop a binary and every user runs it. +- INFO for the inventory, including the coverage cap. + +A locally built binary, a developer tool, or an open-source app the user +compiled is *expected* to be unsigned. Reporting those as malware would recreate +exactly the false-positive problem this module exists to reduce, so the finding +text says what was observed and leaves the judgement to the reader. + +Signature verification is slow — hundreds of milliseconds per binary — so the +scan is capped by count and by wall clock, and the INFO finding states the cap +and whether it was hit. A result that silently examined 40 of 300 applications +while implying a full audit would be worse than no result at all. +""" + +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.command import run +from rescue.fsbounds import is_dir_nofollow + +# Signature checks are expensive; these bound the whole scan. +_MAX_BINARIES = 60 +_PER_COMMAND_TIMEOUT = 15 + +_DARWIN_APP_DIRS = ["/Applications", "~/Applications"] +_SYSTEM_WIDE_PREFIXES = ("/Applications",) + +_WIN_PROGRAM_DIRS = [ + r"C:\Program Files", + r"C:\Program Files (x86)", +] + +def _powershell_signature_command(path: str, limit: int) -> str: + """Build the Authenticode listing command. + + Assembled by concatenation rather than str.format: the PowerShell body is + full of braces, and formatting it would treat them as replacement fields. + """ + return ( + "Get-ChildItem -LiteralPath '" + + path + + "' -Filter *.exe -File -ErrorAction SilentlyContinue | " + "Select-Object -First " + + str(limit) + + " | ForEach-Object { $s = Get-AuthenticodeSignature $_.FullName; " + '"$($_.FullName)|$($s.Status)|$($s.SignerCertificate.Subject)" }' + ) + + +class Module(ModuleBase): + name = "code_signature_audit" + category = "security" + platforms = [Platform.DARWIN, Platform.WIN32] + risk_level = RiskLevel.SAFE + priority = 76 + depends_on = [] + estimated_duration = "60s" + + emits_codes = [ + "security.code_signature_audit.tampered_binary", + "security.code_signature_audit.unsigned_system_app", + "security.code_signature_audit.gatekeeper_rejected", + "security.code_signature_audit.inventory", + ] + + # Scan roots and cap, overridable so tests are deterministic and bounded. + app_dirs: list[str] | None = None + max_binaries: int = _MAX_BINARIES + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform not in (Platform.DARWIN, Platform.WIN32): + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "Code-signature verification is implemented for macOS " + f"(codesign/spctl) and Windows (Authenticode); this host " + f"reports {profile.platform.value}." + ), + ) + + if profile.platform == Platform.DARWIN: + results, examined, total = self._audit_darwin() + else: + results, examined, total = self._audit_windows() + + findings: list[Finding] = [] + + tampered = [r for r in results if r["status"] == "tampered"] + unsigned = [r for r in results if r["status"] == "unsigned"] + rejected = [r for r in results if r["status"] == "gatekeeper_rejected"] + + for entry in tampered: + findings.append( + Finding( + title=f"Signature does not match contents: {entry['name']}", + description=( + f"{entry['path']} carries a code signature, but the signature " + "does not match the file's current contents. That means the " + "application was modified after it was signed.\n\n" + "Unlike an unsigned application, there is no benign explanation " + "for this: legitimate updates are re-signed by the vendor. Treat " + "this application as untrusted until you have reinstalled it " + "from the vendor.\n\n" + f"Reported by: {entry['detail']}" + ), + severity=Severity.CRITICAL, + category=self.category, + code="security.code_signature_audit.tampered_binary", + data={ + "check": "tampered_binary", + "path": entry["path"], + "name": entry["name"], + "detail": entry["detail"], + }, + ) + ) + + for entry in unsigned: + findings.append( + Finding( + title=f"Unsigned application in a system-wide location: {entry['name']}", + description=( + f"{entry['path']} has no valid code signature, and it lives in a " + "location that applies to every user of this machine.\n\n" + "This is evidence, not a verdict. Software you compiled yourself, " + "developer tooling, and some open-source applications are " + "legitimately unsigned. What matters is whether you can account " + "for this one: if you did not install it deliberately, or you do " + "not recognise it, that is worth following up.\n\n" + f"Reported by: {entry['detail']}" + ), + severity=Severity.WARNING, + category=self.category, + code="security.code_signature_audit.unsigned_system_app", + data={ + "check": "unsigned_system_app", + "path": entry["path"], + "name": entry["name"], + "detail": entry["detail"], + }, + ) + ) + + for entry in rejected: + findings.append( + Finding( + title=f"Gatekeeper will not accept: {entry['name']}", + description=( + f"{entry['path']} is signed, but macOS Gatekeeper rejects it — " + "typically because it is not notarised, or the signing identity " + "is not one macOS accepts for distribution.\n\n" + "Common and often benign for software distributed outside the App " + "Store, or installed before notarisation was required. Worth " + "checking against where you got it from.\n\n" + f"Reported by: {entry['detail']}" + ), + severity=Severity.WARNING, + category=self.category, + code="security.code_signature_audit.gatekeeper_rejected", + data={ + "check": "gatekeeper_rejected", + "path": entry["path"], + "name": entry["name"], + "detail": entry["detail"], + }, + ) + ) + + capped = total > examined + findings.append( + Finding( + title=( + f"Signature audit covered {examined} of {total} application(s)" + ), + description=( + f"Examined: {examined}\nPresent in the scanned locations: {total}\n" + f"Valid signatures: {len(results) - len(tampered) - len(unsigned) - len(rejected)}\n" + f"Broken signatures: {len(tampered)}\n" + f"Unsigned: {len(unsigned)}\n" + f"Gatekeeper-rejected: {len(rejected)}\n\n" + + ( + f"COVERAGE LIMIT: the scan stopped at {self.max_binaries} " + "applications because signature verification is slow. This is " + "not a full-disk audit, and applications beyond the cap were not " + "checked at all.\n\n" + if capped + else "All applications in the scanned locations were checked.\n\n" + ) + + "Only signature status was read. No application was opened, " + "modified, quarantined, or sent anywhere." + ), + severity=Severity.INFO, + category=self.category, + code="security.code_signature_audit.inventory", + data={ + "check": "inventory", + "examined": examined, + "total_present": total, + "coverage_capped": capped, + "cap": self.max_binaries, + "tampered": len(tampered), + "unsigned": len(unsigned), + "gatekeeper_rejected": len(rejected), + }, + ) + ) + + return CheckResult(module_name=self.name, findings=findings) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + + for finding in findings.findings: + check = finding.data.get("check") + name = finding.data.get("name", "") + + if check == "tampered_binary": + actions.append( + Action( + title=f"Replace {name} — its signature does not match its contents", + description=( + f"{finding.data.get('path')}\n\n" + " 1. Do not run it again until this is resolved.\n" + " 2. Delete it and reinstall from the vendor's own site or " + "the App Store — not from wherever the current copy came " + "from.\n" + " 3. If you did not modify it yourself (some tools are " + "patched deliberately, e.g. to remove a licence check), treat " + "this machine as potentially compromised and work through the " + "digital_security_reset profile.\n\n" + "Verify it yourself with:\n" + f" codesign --verify --deep --strict --verbose=2 " + f"'{finding.data.get('path')}'" + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": check, "path": finding.data.get("path")}, + ) + ) + + elif check == "unsigned_system_app": + actions.append( + Action( + title=f"Account for the unsigned application {name}", + description=( + f"{finding.data.get('path')}\n\n" + "Ask, in order:\n" + " 1. Did you install this deliberately? If yes, and you got " + "it from a source you trust, unsigned is usually just how it " + "ships.\n" + " 2. Did you build it yourself? Then it is expected.\n" + " 3. If neither — you do not recognise it, or it appeared " + "without you installing it — that is the case worth " + "investigating. Look at when it was installed and what else " + "arrived at the same time.\n\n" + "Inspect it with:\n" + f" codesign -dv --verbose=4 '{finding.data.get('path')}'\n" + f" spctl --assess --type execute --verbose " + f"'{finding.data.get('path')}'" + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": check, "path": finding.data.get("path")}, + ) + ) + + elif check == "gatekeeper_rejected": + actions.append( + Action( + title=f"Check where {name} came from", + description=( + f"{finding.data.get('path')}\n\n" + "Gatekeeper rejecting an application usually means it is not " + "notarised. That is common for smaller developers and for " + "software installed years ago, and is not by itself a sign of " + "compromise.\n\n" + "Confirm it came from the developer's own distribution " + "channel. If it did, no action is needed. If you cannot place " + "it, remove it and reinstall from a known source." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": check, "path": finding.data.get("path")}, + ) + ) + + elif check == "inventory" and finding.data.get("coverage_capped"): + actions.append( + Action( + title="Signature audit did not cover every application", + description=( + f"Only {finding.data.get('examined')} of " + f"{finding.data.get('total_present')} applications were " + "checked, because signature verification takes hundreds of " + "milliseconds per binary.\n\n" + "Do not read a clean result as 'everything is signed'. To " + "check a specific application yourself:\n" + " codesign --verify --deep --strict '/Applications/Name.app'" + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": "coverage_limit"}, + ) + ) + + return FixResult(module_name=self.name, actions=actions) + + # -- detection --------------------------------------------------------- + + def _app_dirs(self, platform: Platform) -> list[str]: + if self.app_dirs is not None: + return self.app_dirs + return _DARWIN_APP_DIRS if platform == Platform.DARWIN else _WIN_PROGRAM_DIRS + + def _collect_darwin_apps(self) -> list[Path]: + apps: list[Path] = [] + for raw_dir in self._app_dirs(Platform.DARWIN): + directory = Path(raw_dir).expanduser() + if not is_dir_nofollow(directory): + continue + try: + entries = sorted(directory.iterdir()) + except OSError: + continue + apps.extend(e for e in entries if e.suffix == ".app") + return apps + + def _audit_darwin(self) -> tuple[list[dict], int, int]: + apps = self._collect_darwin_apps() + total = len(apps) + results: list[dict] = [] + + for app in apps[: self.max_binaries]: + verify = run( + ["codesign", "--verify", "--deep", "--strict", str(app)], + timeout=_PER_COMMAND_TIMEOUT, + ) + entry = { + "path": str(app), + "name": app.stem, + "status": "valid", + "detail": "codesign --verify reported a valid signature", + } + + if not verify.ok: + combined = (verify.stdout + verify.stderr).lower() + if "not signed" in combined or "code object is not signed" in combined: + # Unsigned only counts as a finding in a system-wide location: + # an unsigned app in the user's own ~/Applications is their + # own business and would be pure noise. + if str(app).startswith(_SYSTEM_WIDE_PREFIXES): + entry["status"] = "unsigned" + entry["detail"] = "codesign: code object is not signed at all" + else: + entry["detail"] = "unsigned, in a per-user location" + elif verify.timed_out or verify.error: + entry["detail"] = "signature check did not complete" + else: + entry["status"] = "tampered" + entry["detail"] = ( + "codesign --verify failed on a signed object: " + + (verify.stderr.strip() or verify.stdout.strip() or "no detail") + ) + else: + assess = run( + ["spctl", "--assess", "--type", "execute", str(app)], + timeout=_PER_COMMAND_TIMEOUT, + ) + if not assess.ok and not assess.timed_out and not assess.error: + entry["status"] = "gatekeeper_rejected" + entry["detail"] = ( + "spctl --assess rejected it: " + + (assess.stderr.strip() or assess.stdout.strip() or "no detail") + ) + + results.append(entry) + + return results, len(results), total + + def _audit_windows(self) -> tuple[list[dict], int, int]: + results: list[dict] = [] + total = 0 + + for raw_dir in self._app_dirs(Platform.WIN32): + command = _powershell_signature_command(raw_dir, self.max_binaries) + result = run( + ["powershell", "-NoProfile", "-Command", command], + timeout=_PER_COMMAND_TIMEOUT * 4, + ) + if not result.ok: + continue + + for line in result.stdout.splitlines(): + line = line.strip() + if not line or "|" not in line: + continue + parts = line.split("|") + if len(parts) < 2: + continue + path, status = parts[0].strip(), parts[1].strip() + subject = parts[2].strip() if len(parts) > 2 else "" + total += 1 + if len(results) >= self.max_binaries: + continue + + entry = { + "path": path, + "name": Path(path).stem, + "status": "valid", + "detail": f"Authenticode status {status}" + + (f", signed by {subject}" if subject else ""), + } + normalised = status.lower() + if normalised == "hashmismatch": + entry["status"] = "tampered" + entry["detail"] = ( + "Get-AuthenticodeSignature reported HashMismatch: the file " + "was modified after signing" + ) + elif normalised == "notsigned": + entry["status"] = "unsigned" + entry["detail"] = ( + "Get-AuthenticodeSignature reported NotSigned" + ) + elif normalised not in ("valid",): + entry["status"] = "gatekeeper_rejected" + entry["detail"] = ( + f"Get-AuthenticodeSignature reported {status}" + ) + results.append(entry) + + return results, len(results), max(total, len(results)) 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/evidence_bundle/__init__.py b/modules/security/evidence_bundle/__init__.py new file mode 100644 index 0000000..03371ec --- /dev/null +++ b/modules/security/evidence_bundle/__init__.py @@ -0,0 +1,427 @@ +"""Warn when cleanup is about to destroy the evidence of what happened. + +docs/ROADMAP.md P2, "Evidence collection and forensic handoff": *repair can +destroy the information needed to understand a compromise*. Everything else in +this toolkit is oriented toward fixing things. Fixing things deletes the running +process list, clears the persistence entry, removes the malicious extension — +and with them the only record of how the machine was taken. + +That trade is often worth making. What is not defensible is making it silently, +before anyone has decided. So this module runs early, reports what volatile +evidence exists right now, and warns when compromise indicators are present but +nothing has been preserved. + +Deliberate design decisions: + +**check() never writes a bundle.** Writing files is a real effect on the +machine, and the module contract is that check() observes and fix() proposes. +The bundle is written only by an explicit, human-triggered call. + +**Redaction is not optional.** An evidence bundle is a file people email to +someone else. It never contains passwords, tokens, cookies, keychain items, +private keys, or browsing history. Where a category cannot be collected and +redacted safely, it is omitted and the omission is *recorded in the manifest*, +so the gap is visible to whoever receives it rather than looking like an absence +of evidence. + +**Nothing is ever transmitted.** The bundle is written to local disk. This +module has no network path, by construction. +""" + +import hashlib +import json +from datetime import datetime, timezone +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.command import run + +_COMMAND_TIMEOUT = 20 +_MAX_ITEM_BYTES = 512 * 1024 +_MANIFEST_VERSION = 1 + +# Matches rescue/update/config.py's data directory convention. +_DEFAULT_STATE_DIR = Path.home() / ".local" / "share" / "rescue" + +# Severities that mean "something may have happened here", used to decide +# whether un-preserved evidence is worth warning about. +_COMPROMISE_SEVERITIES = (Severity.CRITICAL, Severity.WARNING) + +_NEVER_COLLECTED = [ + "Passwords, passphrases, and password-manager vaults", + "Authentication tokens, cookies, and session identifiers", + "Keychain and Credential Manager contents", + "Private keys of any kind", + "Browser history and page contents", + "Documents, photos, and other personal files", +] + +_ORDER_OF_OPERATIONS = ( + "Preserve first, then clean. Once you remove a persistence entry or kill a " + "process, the record of how the machine was taken goes with it — and that " + "record is what tells you which accounts to worry about.\n\n" + " 1. If you think an account or money is involved, capture evidence now, " + "before running any fix.\n" + " 2. If this is a work machine, stop and call whoever handles security. Do " + "not clean it yourself; you may be destroying an investigation.\n" + " 3. If the situation involves another person — an ex-partner, a family " + "member, anyone with physical access — evidence may matter legally. Talk to " + "a domestic abuse advocate before you change anything.\n" + " 4. Only then work through remediation." +) + + +class Module(ModuleBase): + name = "evidence_bundle" + category = "security" + platforms = [Platform.DARWIN, Platform.WIN32, Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 90 + depends_on = [] + estimated_duration = "10s" + + emits_codes = [ + "security.evidence_bundle.no_bundle_captured", + "security.evidence_bundle.destination_unwritable", + "security.evidence_bundle.readiness", + ] + + # Overridable so tests never write to the real user state directory. + state_dir: Path | None = None + + def check(self, profile: SystemProfile) -> CheckResult: + """Assess readiness. Deliberately does NOT write a bundle. + + Writing files is a real effect and belongs behind an explicit human + decision, not behind a scan someone ran to look around. + """ + findings: list[Finding] = [] + + destination = self._bundle_root() + writable = self._destination_writable(destination) + collectable = self._collectable_categories(profile) + existing = self._existing_bundles(destination) + + if not writable: + findings.append( + Finding( + title="No writable location for an evidence bundle", + description=( + f"{destination} cannot be written to, so evidence could not be " + "preserved here even if you asked for it.\n\n" + "If this machine may be compromised, capture what you need " + "another way — photograph the screen, write down what you saw and " + "when — before running any repair." + ), + severity=Severity.WARNING, + category=self.category, + code="security.evidence_bundle.destination_unwritable", + data={ + "check": "destination_unwritable", + "destination": str(destination), + }, + ) + ) + + findings.append( + Finding( + title=( + f"Evidence preservation: {len(collectable)} category(ies) " + f"collectable, {len(existing)} bundle(s) already captured" + ), + description=( + "Collectable right now:\n" + + "\n".join(f" {c}" for c in collectable) + + f"\n\nBundles already captured: {len(existing)}" + + ( + "\n" + "\n".join(f" {b}" for b in existing) + if existing + else "" + ) + + "\n\nNever collected, under any circumstances:\n" + + "\n".join(f" {item}" for item in _NEVER_COLLECTED) + + "\n\nNothing has been written and nothing has been sent anywhere. " + "This check only looked at what could be preserved." + ), + severity=Severity.INFO, + category=self.category, + code="security.evidence_bundle.readiness", + data={ + "check": "readiness", + "destination": str(destination), + "destination_writable": writable, + "collectable_categories": collectable, + "existing_bundles": existing, + "bundle_written": False, + }, + ) + ) + + return CheckResult(module_name=self.name, findings=findings) + + def assess_with_context( + self, profile: SystemProfile, other_findings: list[Finding] + ) -> CheckResult: + """check(), plus a warning if indicators exist and nothing is preserved. + + Separate from check() because it needs the rest of the scan's findings, + which a single module does not otherwise see. The orchestrator can call + this instead when it has the full picture. + """ + result = self.check(profile) + indicators = [ + f for f in other_findings if f.severity in _COMPROMISE_SEVERITIES + ] + existing = self._existing_bundles(self._bundle_root()) + + if indicators and not existing: + result.findings.insert( + 0, + Finding( + title=( + f"{len(indicators)} possible compromise indicator(s) found and " + "no evidence captured" + ), + description=( + "This scan found things worth investigating, and no evidence " + "bundle has been captured on this machine.\n\n" + "If you now run repairs, the information that explains what " + "happened — which processes were running, what was set to start " + "automatically, what was listening on the network — will be " + "destroyed along with the problem.\n\n" + + _ORDER_OF_OPERATIONS + ), + severity=Severity.WARNING, + category=self.category, + code="security.evidence_bundle.no_bundle_captured", + data={ + "check": "no_bundle_captured", + "indicator_count": len(indicators), + }, + ), + ) + + return result + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + + actions.append( + Action( + title="Preserve evidence before you repair anything", + description=_ORDER_OF_OPERATIONS, + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": "order_of_operations"}, + ) + ) + + for finding in findings.findings: + check = finding.data.get("check") + + if check == "readiness": + actions.append( + Action( + title="What a handoff to a professional needs", + description=( + "If you take this to an incident responder, a lawyer, or " + "the police, they will want:\n\n" + " 1. When you first noticed something wrong, and what it " + "was. Write it down now; memory degrades fast.\n" + " 2. What you have already changed on the machine. Be " + "honest about this — it changes how they read everything " + "else.\n" + " 3. Whether the machine has been used since.\n" + " 4. Any messages, emails, or charges you noticed.\n\n" + "For a serious case, the strongest evidence is a machine " + "that has been left alone. If you can stop using it, do " + "that rather than trying to collect anything yourself.\n\n" + "This tool never collects: " + + "; ".join(_NEVER_COLLECTED).lower() + + "." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": "handoff"}, + ) + ) + + elif check == "destination_unwritable": + actions.append( + Action( + title="Record what you saw by hand", + description=( + f"{finding.data.get('destination')} is not writable, so " + "capture the basics manually instead:\n\n" + " 1. Photograph any warning, ransom note, or unexpected " + "message with your phone.\n" + " 2. Write down the date and time you noticed it.\n" + " 3. Note any account that behaved oddly, and any charge " + "you did not make.\n\n" + "A phone photograph and a written timeline are genuinely " + "useful evidence. Do not skip this because you cannot " + "produce a technical bundle." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": check}, + ) + ) + + return FixResult(module_name=self.name, actions=actions) + + # -- bundle writing ---------------------------------------------------- + + def write_bundle(self, profile: SystemProfile) -> Path | None: + """Write a redacted, hashed evidence bundle. Explicit call only. + + Never invoked by check() or fix(): this creates files, which is a real + change to the machine and needs a human to have asked for it. + """ + root = self._bundle_root() + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + bundle = root / f"evidence-{stamp}" + + try: + bundle.mkdir(parents=True, exist_ok=True) + except OSError: + return None + + items: list[dict] = [] + omissions: list[dict] = [] + + for category, command in self._collection_commands(profile): + result = run(command, timeout=_COMMAND_TIMEOUT, max_output=_MAX_ITEM_BYTES) + if not result.ok: + omissions.append( + { + "category": category, + "reason": "command did not complete successfully", + "command": " ".join(command), + } + ) + continue + + content = result.stdout[:_MAX_ITEM_BYTES] + filename = category.replace(" ", "_").lower() + ".txt" + try: + (bundle / filename).write_text(content) + except OSError: + omissions.append( + {"category": category, "reason": "could not be written to disk"} + ) + continue + + items.append( + { + "category": category, + "file": filename, + "sha256": hashlib.sha256(content.encode("utf-8", "replace")).hexdigest(), + "bytes": len(content), + "truncated": result.truncated or len(result.stdout) > _MAX_ITEM_BYTES, + "command": " ".join(command), + "collected_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + ) + + manifest = { + "manifest_version": _MANIFEST_VERSION, + "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "platform": profile.platform.value, + "os_name": profile.os_name, + "os_version": profile.os_version, + "items": items, + # Recorded, not silent: a reader must be able to see what is absent + # and why, rather than mistaking a gap for an absence of evidence. + "omitted": omissions, + "never_collected": _NEVER_COLLECTED, + "chain_of_custody": ( + "Generated locally by multiverse-device-rescue. Not transmitted " + "anywhere. Hashes cover file contents as written. This bundle is " + "redacted by design and is not a forensic disk image; it does not " + "establish an unbroken chain of custody on its own." + ), + } + + try: + (bundle / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + ) + except OSError: + return None + + return bundle + + # -- helpers ----------------------------------------------------------- + + def _bundle_root(self) -> Path: + root = Path(self.state_dir) if self.state_dir is not None else _DEFAULT_STATE_DIR + return root / "evidence" + + @staticmethod + def _destination_writable(destination: Path) -> bool: + try: + destination.mkdir(parents=True, exist_ok=True) + except OSError: + return False + probe = destination / ".write_probe" + try: + probe.write_text("") + probe.unlink() + except OSError: + return False + return True + + def _existing_bundles(self, destination: Path) -> list[str]: + try: + return sorted( + entry.name + for entry in destination.iterdir() + if entry.is_dir() and entry.name.startswith("evidence-") + ) + except OSError: + return [] + + @staticmethod + def _collection_commands(profile: SystemProfile) -> list[tuple[str, list[str]]]: + """Commands whose output is safe to preserve after redaction. + + Each yields process/service/network metadata only. Nothing here reads a + credential store, a cookie jar, or a user document. + """ + if profile.platform == Platform.WIN32: + return [ + ("process list", ["tasklist"]), + ("network connections", ["netstat", "-ano"]), + ("scheduled tasks", ["schtasks", "/query", "/fo", "list"]), + ("services", ["net", "start"]), + ] + if profile.platform == Platform.DARWIN: + return [ + ("process list", ["ps", "aux"]), + ("network connections", ["netstat", "-an"]), + ("launch agents", ["launchctl", "list"]), + ] + return [ + ("process list", ["ps", "aux"]), + ("network connections", ["ss", "-tulpn"]), + ("systemd units", ["systemctl", "list-units", "--type=service", "--no-pager"]), + ] + + def _collectable_categories(self, profile: SystemProfile) -> list[str]: + return [category for category, _ in self._collection_commands(profile)] diff --git a/modules/security/kext_audit/__init__.py b/modules/security/kext_audit/__init__.py index dff7e27..dc7f29e 100644 --- a/modules/security/kext_audit/__init__.py +++ b/modules/security/kext_audit/__init__.py @@ -46,6 +46,11 @@ class Module(ModuleBase): depends_on = [] estimated_duration = "3s" + # Scanned for third-party kexts. An attribute so a test can point it at a + # fixture directory: the existence check below runs before the `find` call, + # so on a non-macOS host the scan silently returned nothing. + extensions_dir = "/Library/Extensions" + emits_codes = [ "security.kext_audit.loaded_third_party_kext", "security.kext_audit.kext_file", @@ -249,7 +254,7 @@ def _is_unsigned_kext(self, kext: dict) -> bool: def _get_kext_files(self) -> list[str]: """Find kext files in /Library/Extensions/""" kext_files = [] - extensions_dir = "/Library/Extensions" + extensions_dir = str(self.extensions_dir) if not os.path.exists(extensions_dir): return kext_files @@ -259,6 +264,7 @@ def _get_kext_files(self) -> list[str]: ["find", extensions_dir, "-name", "*.kext", "-type", "d"], capture_output=True, text=True, + timeout=15, ) if result.returncode == 0: for line in result.stdout.strip().split("\n"): diff --git a/modules/security/password_manager_check/__init__.py b/modules/security/password_manager_check/__init__.py new file mode 100644 index 0000000..b79e8f2 --- /dev/null +++ b/modules/security/password_manager_check/__init__.py @@ -0,0 +1,354 @@ +"""Check whether this machine has a password manager, and where passwords live. + +The roadmap named ``password_manager_check`` as part of the digital security +reset and it was never built; the profile was corrected by deleting the +reference. This is the real thing. + +What it does *not* do is as important as what it does. It never opens a vault, +never reads a credential store, and never asks for a master password. It looks +only at whether password-manager software is installed and whether browsers are +configured to hold passwords themselves. That is enough to answer the question +the security-reset guide actually asks — "is there somewhere safe to put new +passwords before you start changing them?" — without this tool ever touching a +secret. + +Browser-stored passwords are reported as a weaker posture, not as a finding of +wrongdoing: they are encrypted at rest by the browser, but they are unlocked by +the OS login session, sync to wherever that account syncs, and cannot be shared +or audited. Presence is detected from the profile directory only; the credential +database is never opened. +""" + +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.command import run +from rescue.fsbounds import is_dir_nofollow + +_COMMAND_TIMEOUT = 20 + +# Dedicated password managers, keyed by the name shown to the user. +# "app" entries are matched against .app bundle names in the macOS application +# directories; "windows" entries are matched against installed-program display +# names. Matching is case-insensitive and by prefix, so version suffixes in +# Windows display names ("1Password 8") still match. +_MANAGERS = [ + {"name": "1Password", "app": ["1Password", "1Password 7", "1Password 8"], "windows": ["1Password"]}, + {"name": "Bitwarden", "app": ["Bitwarden"], "windows": ["Bitwarden"]}, + {"name": "KeePassXC", "app": ["KeePassXC"], "windows": ["KeePassXC"]}, + {"name": "KeePass", "app": ["KeePass"], "windows": ["KeePass"]}, + {"name": "Dashlane", "app": ["Dashlane"], "windows": ["Dashlane"]}, + {"name": "Enpass", "app": ["Enpass"], "windows": ["Enpass"]}, + {"name": "NordPass", "app": ["NordPass"], "windows": ["NordPass"]}, + {"name": "Proton Pass", "app": ["Proton Pass"], "windows": ["Proton Pass"]}, + {"name": "LastPass", "app": ["LastPass"], "windows": ["LastPass"]}, + {"name": "Strongbox", "app": ["Strongbox"], "windows": []}, + {"name": "Secrets", "app": ["Secrets"], "windows": []}, +] + +_DARWIN_APP_DIRS = ["/Applications", "~/Applications"] + +_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", +] + +# Browser profile locations. Presence of the profile directory is the signal; +# the credential databases inside are never opened. +_DARWIN_BROWSER_PROFILES = { + "Google Chrome": "~/Library/Application Support/Google/Chrome", + "Microsoft Edge": "~/Library/Application Support/Microsoft Edge", + "Brave": "~/Library/Application Support/BraveSoftware/Brave-Browser", + "Firefox": "~/Library/Application Support/Firefox/Profiles", + "Safari": "~/Library/Safari", +} + +_WIN_BROWSER_PROFILES = { + "Google Chrome": "~/AppData/Local/Google/Chrome/User Data", + "Microsoft Edge": "~/AppData/Local/Microsoft/Edge/User Data", + "Brave": "~/AppData/Local/BraveSoftware/Brave-Browser/User Data", + "Firefox": "~/AppData/Roaming/Mozilla/Firefox/Profiles", +} + +# Built-in OS credential stores. Real password managers for the platform's own +# ecosystem, but they do not cover a second OS or a shared household, so their +# presence does not by itself clear the "no manager" warning. +_BUILTIN = { + Platform.DARWIN: "Apple Passwords / iCloud Keychain", + Platform.WIN32: "Windows Credential Manager", +} + + +class Module(ModuleBase): + name = "password_manager_check" + category = "security" + platforms = [Platform.DARWIN, Platform.WIN32] + risk_level = RiskLevel.SAFE + priority = 78 + depends_on = [] + estimated_duration = "10s" + + emits_codes = [ + "security.password_manager_check.no_password_manager", + "security.password_manager_check.browser_stored_passwords", + "security.password_manager_check.inventory", + ] + + # Roots this module reads, as attributes so tests can point them at a + # fixture tree rather than the running user's real machine. + app_dirs: list[str] | None = None + browser_profiles: dict[str, str] | None = None + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform not in (Platform.DARWIN, Platform.WIN32): + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "Password-manager detection is implemented for macOS and " + f"Windows; this host reports {profile.platform.value}." + ), + ) + + findings: list[Finding] = [] + + if profile.platform == Platform.DARWIN: + managers = self._find_darwin_managers() + else: + managers = self._find_windows_managers() + + browsers = self._find_browser_profiles(profile) + builtin = _BUILTIN.get(profile.platform) + + if not managers: + findings.append( + Finding( + title="No dedicated password manager found", + description=( + "No dedicated password manager is installed on this machine. " + f"{builtin} is available on this platform and is genuinely " + "better than reusing passwords, but it does not follow you to " + "another operating system and cannot be shared or audited.\n\n" + "This matters most right now if you are working through a " + "security reset: you need somewhere to put new, unique " + "passwords before you start changing them, or you will end up " + "reusing one again." + + ( + "\n\nBrowsers holding passwords on this machine: " + + ", ".join(browsers) + if browsers + else "" + ) + ), + severity=Severity.WARNING, + category=self.category, + code="security.password_manager_check.no_password_manager", + data={ + "check": "no_password_manager", + "browsers_with_profiles": browsers, + "platform_builtin": builtin, + }, + ) + ) + + if browsers and not managers: + findings.append( + Finding( + title=f"Passwords may be stored in {len(browsers)} browser(s)", + description=( + "These browsers keep their own password stores and no dedicated " + "manager was found alongside them: " + + ", ".join(browsers) + + ".\n\n" + "Browser-stored passwords are encrypted on disk, but they unlock " + "with your operating-system login, so anything running as you can " + "read them, and they sync wherever that browser account syncs. " + "This module only looked at whether the browser profile exists — " + "it did not open any password database." + ), + severity=Severity.WARNING, + category=self.category, + code="security.password_manager_check.browser_stored_passwords", + data={ + "check": "browser_stored_passwords", + "browsers": browsers, + }, + ) + ) + + findings.append( + Finding( + title=( + f"Password storage inventory: {len(managers)} manager(s), " + f"{len(browsers)} browser profile(s)" + ), + description=( + "Password managers found: " + + (", ".join(managers) if managers else "none") + + "\nBrowser profiles present: " + + (", ".join(browsers) if browsers else "none") + + f"\nPlatform credential store: {builtin}" + + "\n\nThis is an inventory of installed software and profile " + "directories. No vault, keychain, or password database was opened." + ), + severity=Severity.INFO, + category=self.category, + code="security.password_manager_check.inventory", + data={ + "check": "inventory", + "managers": managers, + "browsers": browsers, + "platform_builtin": builtin, + }, + ) + ) + + return CheckResult(module_name=self.name, findings=findings) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + + for finding in findings.findings: + check = finding.data.get("check") + + if check == "no_password_manager": + actions.append( + Action( + title="Set up a password manager", + description=( + "Pick one and put it in place before changing any passwords, " + "so each new password can be unique and you do not have to " + "remember them.\n\n" + " 1. Choose a manager. Bitwarden and KeePassXC are free and " + "open source; 1Password and Proton Pass are paid. Any of them " + "beats reuse.\n" + " 2. Set a long passphrase for it — several unrelated words, " + "not a short complex string. This is the one you memorise.\n" + " 3. Turn on two-factor authentication for the manager itself.\n" + " 4. Write the recovery kit or emergency code on paper and " + "store it somewhere physically safe. If you lose it, nobody " + "can recover the vault for you — that is the point.\n" + " 5. Add accounts as you change their passwords, starting " + "with the email address the other accounts reset through.\n\n" + "Never type your master password into this tool, or into " + "anything that asks for it outside the manager itself." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": check}, + ) + ) + + elif check == "browser_stored_passwords": + browsers = finding.data.get("browsers", []) + actions.append( + Action( + title="Move passwords out of browser storage", + description=( + "Browsers holding passwords here: " + + ", ".join(browsers) + + "\n\n" + " 1. Set up a password manager first (see the other action).\n" + " 2. Export from the browser's password settings, import into " + "the manager, then delete the export file — it is plain text, " + "and it is the most dangerous file on your disk while it exists.\n" + " 3. Turn off the browser's offer to save passwords.\n" + " 4. Clear the browser's saved passwords once you have " + "confirmed they are in the manager.\n\n" + "If you are doing this because of a compromise, change the " + "passwords as you move them rather than importing the old ones." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": check, "browsers": browsers}, + ) + ) + + return FixResult(module_name=self.name, actions=actions) + + # -- detection --------------------------------------------------------- + + def _app_dirs(self) -> list[str]: + return self.app_dirs if self.app_dirs is not None else _DARWIN_APP_DIRS + + def _find_darwin_managers(self) -> list[str]: + """Match .app bundle names in the application directories. + + Only the top level of each directory is listed: application bundles live + there, and descending into them would mean walking the whole of + /Applications for no gain. + """ + installed: set[str] = set() + for raw_dir in self._app_dirs(): + directory = Path(raw_dir).expanduser() + if not is_dir_nofollow(directory): + continue + try: + entries = list(directory.iterdir()) + except OSError: + continue + for entry in entries: + if entry.suffix != ".app": + continue + bundle = entry.stem.lower() + for manager in _MANAGERS: + if any(bundle.startswith(a.lower()) for a in manager["app"]): + installed.add(manager["name"]) + # First match wins. _MANAGERS lists the more specific + # name first (KeePassXC before KeePass) so a prefix match + # does not report both for a single application. + break + return sorted(installed) + + def _find_windows_managers(self) -> list[str]: + """Match installed-program display names in the uninstall registry keys.""" + installed: set[str] = set() + for key in _WIN_UNINSTALL_KEYS: + result = run( + ["reg", "query", key, "/s", "/v", "DisplayName"], + timeout=_COMMAND_TIMEOUT, + ) + if not result.ok: + continue + for line in result.stdout.splitlines(): + if "DisplayName" not in line: + continue + # REG_SZ values are tab/space separated: name, type, then value. + parts = line.split("REG_SZ") + if len(parts) < 2: + continue + display = parts[1].strip().lower() + for manager in _MANAGERS: + if any(display.startswith(w.lower()) for w in manager["windows"]): + installed.add(manager["name"]) + break + return sorted(installed) + + def _find_browser_profiles(self, profile: SystemProfile) -> list[str]: + if self.browser_profiles is not None: + table = self.browser_profiles + elif profile.platform == Platform.DARWIN: + table = _DARWIN_BROWSER_PROFILES + else: + table = _WIN_BROWSER_PROFILES + + present = [] + for browser, raw_path in table.items(): + if is_dir_nofollow(Path(raw_path).expanduser()): + present.append(browser) + return sorted(present) diff --git a/modules/security/security_baseline_diff/__init__.py b/modules/security/security_baseline_diff/__init__.py new file mode 100644 index 0000000..e743cb0 --- /dev/null +++ b/modules/security/security_baseline_diff/__init__.py @@ -0,0 +1,528 @@ +"""Record what this machine looks like, then report only what changed. + +docs/ROADMAP.md P2, "Baselines and differential scans": *a single snapshot +cannot identify what changed*. Every other module in this toolkit answers "does +this look bad right now", which forces it to guess at intent — is that launch +agent malware, or something you installed on purpose? A baseline sidesteps the +guess. A launch agent that has been there since the first scan is part of the +machine. One that appeared this week is a question. + +What is captured: the persistence surface (launch agents/daemons, scheduled +tasks), listening network ports, installed browser extensions, and the on/off +state of the main protections. All of it comes from the already-gathered +SystemProfile or a small number of bounded commands. + +Two properties matter more than the diffing itself: + +**Trust on first use.** If the machine was already compromised when the baseline +was taken, the compromise is baked into the baseline and this module will never +mention it again. That is a real limitation, not a footnote, so it is stated in +the finding text every time rather than buried here. + +**Asymmetric severity.** A protection turning *off* is a warning; turning *on* +is not. A new listening port is a warning; one that closed is not. Reporting +every difference symmetrically would bury the four that matter under forty that +do not, which is how differential tools get ignored. +""" + +import json +from datetime import datetime, timezone +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.command import run +from rescue.fsbounds import is_dir_nofollow + +_COMMAND_TIMEOUT = 20 +_BASELINE_VERSION = 1 + +# Matches rescue/update/config.py's data directory convention. +_DEFAULT_STATE_DIR = Path.home() / ".local" / "share" / "rescue" + +_TOFU_CAVEAT = ( + "This comparison is only as trustworthy as the first scan. If this machine " + "was already compromised when the baseline was captured, whatever was " + "already present is recorded as normal and will not be reported here." +) + +_DARWIN_PERSISTENCE_DIRS = [ + "~/Library/LaunchAgents", + "/Library/LaunchAgents", + "/Library/LaunchDaemons", +] + +_DARWIN_EXTENSION_DIRS = [ + "~/Library/Application Support/Google/Chrome/Default/Extensions", + "~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Extensions", + "~/Library/Application Support/Microsoft Edge/Default/Extensions", +] + +_WIN_EXTENSION_DIRS = [ + "~/AppData/Local/Google/Chrome/User Data/Default/Extensions", + "~/AppData/Local/Microsoft/Edge/User Data/Default/Extensions", +] + + +class Module(ModuleBase): + name = "security_baseline_diff" + category = "security" + platforms = [Platform.DARWIN, Platform.WIN32, Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 74 + depends_on = [] + estimated_duration = "20s" + + emits_codes = [ + "security.security_baseline_diff.baseline_established", + "security.security_baseline_diff.new_persistence", + "security.security_baseline_diff.new_listening_port", + "security.security_baseline_diff.new_browser_extension", + "security.security_baseline_diff.protection_disabled", + "security.security_baseline_diff.no_change", + ] + + # Overridable so tests never write to the real user state directory. + state_dir: Path | None = None + persistence_dirs: list[str] | None = None + extension_dirs: list[str] | None = None + + def check(self, profile: SystemProfile) -> CheckResult: + current = self._capture(profile) + previous = self._load_baseline() + + if previous is None: + saved = self._save_baseline(current) + return CheckResult( + module_name=self.name, + findings=[ + Finding( + title="Security baseline established", + description=( + "This is the first run, so there was nothing to compare " + "against. A baseline has been recorded and future scans will " + "report only what changed since now.\n\n" + f"Recorded: {len(current['persistence'])} persistence item(s), " + f"{len(current['listening_ports'])} listening port(s), " + f"{len(current['browser_extensions'])} browser extension(s), " + f"{len(current['protections'])} protection setting(s).\n\n" + + _TOFU_CAVEAT + + "\n\nIf you have any reason to think this machine is already " + "compromised, deal with that first — run the " + "digital_security_reset profile — and re-baseline afterwards." + + ( + f"\n\nBaseline stored at: {saved}" + if saved + else "\n\nThe baseline could not be written to disk, so the " + "next run will start over." + ) + ), + severity=Severity.INFO, + category=self.category, + code="security.security_baseline_diff.baseline_established", + data={ + "check": "baseline_established", + "counts": {k: len(v) for k, v in current.items() if isinstance(v, (list, dict))}, + "baseline_path": str(saved) if saved else None, + }, + ) + ], + ) + + findings = self._diff(previous, current) + + if not findings: + findings.append( + Finding( + title="No security-relevant changes since the last baseline", + description=( + "Nothing was added to the persistence surface, no new port " + "started listening, no new browser extension appeared, and no " + "protection was turned off since " + f"{previous.get('captured_at', 'the baseline')}.\n\n" + + _TOFU_CAVEAT + ), + severity=Severity.INFO, + category=self.category, + code="security.security_baseline_diff.no_change", + data={ + "check": "no_change", + "baseline_captured_at": previous.get("captured_at"), + }, + ) + ) + + return CheckResult(module_name=self.name, findings=findings) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + checks = {f.data.get("check") for f in findings.findings} + + for finding in findings.findings: + check = finding.data.get("check") + + if check == "new_persistence": + actions.append( + Action( + title="Account for the new persistence items", + description=( + "These arrived since the baseline:\n\n" + + "\n".join(f" {i}" for i in finding.data.get("added", [])) + + "\n\nPersistence items run automatically. Ask what you " + "installed or updated around this time — a new app " + "legitimately adds one. If you cannot account for an entry, " + "that is the one to investigate: look at what it runs, and " + "when the file was created.\n\n" + "Do not delete entries you merely do not recognise; plenty of " + "legitimate software uses obscure names. Identify first." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": check}, + ) + ) + + elif check == "new_listening_port": + actions.append( + Action( + title="Account for the newly listening ports", + description=( + "These are accepting connections and were not doing so at " + "baseline:\n\n" + + "\n".join(f" {p}" for p in finding.data.get("added", [])) + + "\n\nA listening port is a way in. Match each one to " + "software you deliberately run. Pay particular attention to " + "any bound to 0.0.0.0 rather than 127.0.0.1 — those are " + "reachable from the network, not just this machine." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": check}, + ) + ) + + elif check == "new_browser_extension": + actions.append( + Action( + title="Review the new browser extensions", + description=( + "New since baseline:\n\n" + + "\n".join(f" {e}" for e in finding.data.get("added", [])) + + "\n\nExtensions can read and change every page you visit, " + "including your email and your bank. Remove any you did not " + "install yourself. An extension you did not add is a strong " + "signal — it usually means something else installed it." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": check}, + ) + ) + + elif check == "protection_disabled": + actions.append( + Action( + title="Turn the disabled protections back on", + description=( + "These were on at baseline and are off now:\n\n" + + "\n".join( + f" {p}" for p in finding.data.get("disabled", []) + ) + + "\n\nIf you turned one off deliberately, turn it back on " + "when you are done. If you did not, this is the most " + "important finding in this report: disabling protection is " + "something malware does early, and something an attacker with " + "access does before anything else." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": check}, + ) + ) + + if "baseline_established" in checks or "no_change" in checks: + actions.append( + Action( + title="Re-baseline deliberately after changes you made", + description=( + "When you have installed something new and confirmed the changes " + "it made are expected, delete the baseline file so the next scan " + "records a fresh one. Otherwise your own software keeps being " + "reported as a change.\n\n" + "Only re-baseline when you are confident the current state is " + "clean. Re-baselining a compromised machine tells this module to " + "treat the compromise as normal from then on.\n\n" + + _TOFU_CAVEAT + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": "rebaseline"}, + ) + ) + + return FixResult(module_name=self.name, actions=actions) + + # -- capture ----------------------------------------------------------- + + def _capture(self, profile: SystemProfile) -> dict: + return { + "version": _BASELINE_VERSION, + "captured_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "platform": profile.platform.value, + "persistence": sorted(self._capture_persistence(profile)), + "listening_ports": sorted(self._capture_listening_ports(profile)), + "browser_extensions": sorted(self._capture_extensions(profile)), + "protections": self._capture_protections(profile), + } + + def _persistence_dirs(self) -> list[str]: + if self.persistence_dirs is not None: + return self.persistence_dirs + return _DARWIN_PERSISTENCE_DIRS + + def _capture_persistence(self, profile: SystemProfile) -> list[str]: + if profile.platform == Platform.WIN32: + result = run(["schtasks", "/query", "/fo", "csv"], timeout=_COMMAND_TIMEOUT) + if not result.ok: + return [] + names = [] + for line in result.stdout.splitlines()[1:]: + parts = line.split(",") + if len(parts) > 1: + names.append(parts[1].strip().strip('"')) + return [n for n in names if n] + + items: list[str] = [] + for raw_dir in self._persistence_dirs(): + directory = Path(raw_dir).expanduser() + if not is_dir_nofollow(directory): + continue + try: + entries = list(directory.iterdir()) + except OSError: + continue + items.extend( + f"{directory}/{e.name}" for e in entries if e.suffix == ".plist" + ) + return items + + def _capture_listening_ports(self, profile: SystemProfile) -> list[str]: + """Listening sockets, as 'address:port'. Never records process arguments.""" + if profile.platform == Platform.WIN32: + result = run(["netstat", "-an"], timeout=_COMMAND_TIMEOUT) + else: + result = run(["netstat", "-an"], timeout=_COMMAND_TIMEOUT) + if not result.ok: + return [] + + ports = set() + for line in result.stdout.splitlines(): + if "LISTEN" not in line.upper(): + continue + parts = line.split() + for token in parts: + if (":" in token or "." in token) and any(c.isdigit() for c in token): + ports.add(token) + break + return sorted(ports) + + def _extension_dirs(self, profile: SystemProfile) -> list[str]: + if self.extension_dirs is not None: + return self.extension_dirs + return ( + _WIN_EXTENSION_DIRS + if profile.platform == Platform.WIN32 + else _DARWIN_EXTENSION_DIRS + ) + + def _capture_extensions(self, profile: SystemProfile) -> list[str]: + found: list[str] = [] + for raw_dir in self._extension_dirs(profile): + directory = Path(raw_dir).expanduser() + if not is_dir_nofollow(directory): + continue + try: + entries = list(directory.iterdir()) + except OSError: + continue + found.extend(f"{directory.name}/{e.name}" for e in entries if is_dir_nofollow(e)) + return found + + def _capture_protections(self, profile: SystemProfile) -> dict: + """On/off state of the main protections, as a name -> bool map.""" + protections: dict[str, bool] = {} + + if profile.platform == Platform.DARWIN: + firewall = run( + ["/usr/libexec/ApplicationFirewall/socketfilterfw", "--getglobalstate"], + timeout=_COMMAND_TIMEOUT, + ) + if firewall.ok: + protections["Application firewall"] = "enabled" in firewall.stdout.lower() + + filevault = run(["fdesetup", "status"], timeout=_COMMAND_TIMEOUT) + if filevault.ok: + protections["FileVault"] = "is on" in filevault.stdout.lower() + + sip = run(["csrutil", "status"], timeout=_COMMAND_TIMEOUT) + if sip.ok: + protections["System Integrity Protection"] = "enabled" in sip.stdout.lower() + + elif profile.platform == Platform.WIN32: + defender = run( + [ + "powershell", + "-NoProfile", + "-Command", + "(Get-MpComputerStatus).RealTimeProtectionEnabled", + ], + timeout=_COMMAND_TIMEOUT, + ) + if defender.ok: + protections["Defender real-time protection"] = ( + "true" in defender.stdout.strip().lower() + ) + + return protections + + # -- storage ----------------------------------------------------------- + + def _baseline_path(self) -> Path: + root = Path(self.state_dir) if self.state_dir is not None else _DEFAULT_STATE_DIR + return root / "security_baseline.json" + + def _load_baseline(self) -> dict | None: + path = self._baseline_path() + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + return None + if not isinstance(data, dict) or data.get("version") != _BASELINE_VERSION: + # An unreadable or older-format baseline is treated as absent rather + # than half-compared against a schema it does not match. + return None + return data + + def _save_baseline(self, snapshot: dict) -> Path | None: + path = self._baseline_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(snapshot, indent=2, sort_keys=True)) + except OSError: + return None + return path + + # -- diff -------------------------------------------------------------- + + def _diff(self, previous: dict, current: dict) -> list[Finding]: + findings: list[Finding] = [] + when = previous.get("captured_at", "the baseline") + + # Written as `code="..."` keyword literals, not built from `check`: + # test_module_code_consistency matches emits_codes against the code= + # string literals in this file, and an f-string is invisible to it. + additions = [ + dict( + key="persistence", + check="new_persistence", + code="security.security_baseline_diff.new_persistence", + label="persistence item", + severity=Severity.WARNING, + ), + dict( + key="listening_ports", + check="new_listening_port", + code="security.security_baseline_diff.new_listening_port", + label="listening port", + severity=Severity.WARNING, + ), + dict( + key="browser_extensions", + check="new_browser_extension", + code="security.security_baseline_diff.new_browser_extension", + label="browser extension", + severity=Severity.WARNING, + ), + ] + + for spec in additions: + key, check = spec["key"], spec["check"] + code, label, severity = spec["code"], spec["label"], spec["severity"] + before = set(previous.get(key) or []) + after = set(current.get(key) or []) + added = sorted(after - before) + if not added: + continue + findings.append( + Finding( + title=f"{len(added)} new {label}(s) since {when}", + description=( + f"These were not present at the baseline taken {when}:\n\n" + + "\n".join(f" {item}" for item in added) + + "\n\nA new entry is not automatically bad — installing software " + "adds them. It is worth checking because you can now ask a much " + "sharper question than 'does this look suspicious': did you " + "install something around this time?\n\n" + + _TOFU_CAVEAT + ), + severity=severity, + category=self.category, + code=code, + data={ + "check": check, + "added": added, + "baseline_captured_at": when, + }, + ) + ) + + before_prot = previous.get("protections") or {} + after_prot = current.get("protections") or {} + disabled = sorted( + name + for name, was_on in before_prot.items() + # Only a genuine on -> off transition. A protection that has vanished + # from the current capture (command unavailable) is not "disabled". + if was_on and after_prot.get(name) is False + ) + if disabled: + findings.append( + Finding( + title=f"{len(disabled)} protection(s) turned off since {when}", + description=( + "These were enabled at the baseline and are disabled now:\n\n" + + "\n".join(f" {name}" for name in disabled) + + "\n\nIf you did not turn these off yourself, treat it as the " + "most serious item in this report. Disabling protection is an " + "early step for both malware and a person with access to the " + "machine.\n\n" + + _TOFU_CAVEAT + ), + severity=Severity.CRITICAL, + category=self.category, + code="security.security_baseline_diff.protection_disabled", + data={ + "check": "protection_disabled", + "disabled": disabled, + "baseline_captured_at": when, + }, + ) + ) + + return findings diff --git a/modules/security/session_revocation_scan/__init__.py b/modules/security/session_revocation_scan/__init__.py new file mode 100644 index 0000000..5de7d87 --- /dev/null +++ b/modules/security/session_revocation_scan/__init__.py @@ -0,0 +1,392 @@ +"""Inventory the sign-in surfaces that survive a password change. + +The roadmap named ``session_revocation_scan`` for the digital security reset and +it was never built. This is the check someone runs after a compromise, when the +question is not "how did they get in" but "what is *still* logged in, and what +do I have to kick out". + +That question matters because changing a password does not, on its own, end +existing sessions. A live browser cookie, an OAuth grant, an app-specific +password, or an authorised SSH key all keep working afterwards. People routinely +change their password, believe they are done, and stay compromised. + +What this module reports is the *surface*: how many browser profiles hold their +own cookie stores, which system accounts are configured, what remote-access +grants exist. It never reads a cookie database, a keychain item, a token value, +or private key material. For SSH it reports the comment and fingerprint-bearing +line count needed to identify an entry, not the key itself. + +This deliberately overlaps with ``remote_login_check`` and ``ssh_key_audit``: +those answer "is remote access enabled and are these keys sane". This one +answers "what must I revoke", and orders the guidance so the password change +happens *before* the sign-out, which is the part people get backwards. +""" + +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.command import run +from rescue.fsbounds import is_dir_nofollow, is_file_nofollow + +_COMMAND_TIMEOUT = 20 +_MAX_AUTHORIZED_KEYS_LINES = 500 + +_DARWIN_BROWSER_PROFILE_ROOTS = { + "Google Chrome": "~/Library/Application Support/Google/Chrome", + "Microsoft Edge": "~/Library/Application Support/Microsoft Edge", + "Brave": "~/Library/Application Support/BraveSoftware/Brave-Browser", + "Firefox": "~/Library/Application Support/Firefox/Profiles", + "Safari": "~/Library/Safari", +} + +_WIN_BROWSER_PROFILE_ROOTS = { + "Google Chrome": "~/AppData/Local/Google/Chrome/User Data", + "Microsoft Edge": "~/AppData/Local/Microsoft/Edge/User Data", + "Brave": "~/AppData/Local/BraveSoftware/Brave-Browser/User Data", + "Firefox": "~/AppData/Roaming/Mozilla/Firefox/Profiles", +} + +_SSH_AUTHORIZED_KEYS = "~/.ssh/authorized_keys" + +_REVOCATION_ORDER = ( + "Do this in order. Revoking sessions before changing the password just lets " + "whoever has the password sign straight back in:\n\n" + " 1. Change the password — on the email account first, then everything that " + "resets through it.\n" + " 2. THEN sign out of all other devices/sessions, from the account's own " + "security page.\n" + " 3. Review and revoke third-party app access (OAuth grants). These survive " + "both a password change and a sign-out-everywhere on many providers.\n" + " 4. Revoke app-specific passwords and long-lived API tokens.\n" + " 5. Remove any SSH keys and remote-access grants you do not recognise." +) + + +class Module(ModuleBase): + name = "session_revocation_scan" + category = "security" + platforms = [Platform.DARWIN, Platform.WIN32] + risk_level = RiskLevel.SAFE + priority = 81 + depends_on = [] + estimated_duration = "15s" + + emits_codes = [ + "security.session_revocation_scan.browser_sessions", + "security.session_revocation_scan.system_accounts", + "security.session_revocation_scan.ssh_authorized_keys", + "security.session_revocation_scan.inventory", + ] + + # Read roots, overridable so tests never touch the host machine. + browser_profile_roots: dict[str, str] | None = None + authorized_keys_path: Path | None = None + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform not in (Platform.DARWIN, Platform.WIN32): + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "Session-surface inventory is implemented for macOS and " + f"Windows; this host reports {profile.platform.value}." + ), + ) + + findings: list[Finding] = [] + + browsers = self._browser_sessions(profile) + accounts = self._system_accounts(profile) + ssh_keys = self._authorized_keys_count() + + if browsers: + total = sum(b["profiles"] for b in browsers) + detail = "\n".join( + f" {b['browser']}: {b['profiles']} profile(s)" for b in browsers + ) + findings.append( + Finding( + title=f"{total} browser profile(s) hold their own sign-in sessions", + description=( + "Each browser profile keeps its own cookies, so each one can stay " + "signed in to your accounts independently — and stays signed in " + "after you change the password, until you explicitly sign out " + "everywhere.\n\n" + f"{detail}\n\n" + "Only the profile directories were counted. No cookie database, " + "saved password, or session token was opened or read." + ), + severity=Severity.WARNING, + category=self.category, + code="security.session_revocation_scan.browser_sessions", + data={ + "check": "browser_sessions", + "browsers": browsers, + "profile_count": total, + }, + ) + ) + + if accounts: + findings.append( + Finding( + title=f"{len(accounts)} system account(s) signed in on this device", + description=( + "These accounts are configured on the device itself and keep " + "their own tokens, independent of any browser session:\n\n" + + "\n".join(f" {a}" for a in accounts) + + "\n\nAccount names are listed to identify them. No token or " + "credential value was read." + ), + severity=Severity.WARNING, + category=self.category, + code="security.session_revocation_scan.system_accounts", + data={"check": "system_accounts", "accounts": accounts}, + ) + ) + + if ssh_keys > 0: + findings.append( + Finding( + title=f"{ssh_keys} SSH key(s) authorised for remote login to this machine", + description=( + f"~/.ssh/authorized_keys grants {ssh_keys} key(s) the ability to " + "log in to this machine remotely. An authorised key is unaffected " + "by changing your account password — it is a separate credential, " + "and it is a common way access is retained after a compromise.\n\n" + "Only the number of authorised entries was counted. No key " + "material was read or reported." + ), + severity=Severity.WARNING, + category=self.category, + code="security.session_revocation_scan.ssh_authorized_keys", + data={"check": "ssh_authorized_keys", "key_count": ssh_keys}, + ) + ) + + findings.append( + Finding( + title="Session revocation surface inventory", + description=( + f"Browser profiles: {sum(b['profiles'] for b in browsers)}\n" + f"System accounts: {len(accounts)}\n" + f"Authorised SSH keys: {ssh_keys}\n\n" + "This is the local surface only. Sessions held by your accounts at " + "their providers — active logins, OAuth app grants, app-specific " + "passwords — are not visible from this device and must be reviewed " + "on each provider's own security page." + ), + severity=Severity.INFO, + category=self.category, + code="security.session_revocation_scan.inventory", + data={ + "check": "inventory", + "browser_profile_count": sum(b["profiles"] for b in browsers), + "system_account_count": len(accounts), + "ssh_key_count": ssh_keys, + "provider_sessions_checked": False, + }, + ) + ) + + return CheckResult(module_name=self.name, findings=findings) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + checks = {f.data.get("check") for f in findings.findings} + + actions.append( + Action( + title="Revoke sessions in the right order", + description=_REVOCATION_ORDER, + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": "revocation_order"}, + ) + ) + + if "browser_sessions" in checks: + actions.append( + Action( + title="Sign out of browser sessions", + description=( + "After the password change, for each account:\n\n" + " Google: myaccount.google.com > Security > Your devices > " + "Sign out\n" + " Microsoft: account.microsoft.com/devices\n" + " Apple: appleid.apple.com > Devices\n" + " Facebook/Instagram: Settings > Password and security > " + "Where you're logged in\n\n" + "Then clear cookies in each browser profile so any local session " + "is gone too. Signing out on the provider is the part that " + "matters; clearing cookies alone does not revoke anything " + "server-side." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": "browser_sessions"}, + ) + ) + + if "system_accounts" in checks: + actions.append( + Action( + title="Review accounts configured on this device", + description=( + "These hold their own tokens on the device:\n\n" + " macOS: System Settings > Internet Accounts, and " + "System Settings > [your name]\n" + " Windows: Settings > Accounts > Email & accounts, and " + "Access work or school\n\n" + "Remove any you do not recognise or no longer use. Removing an " + "account here deletes the device's stored token for it; it does " + "not delete the account." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": "system_accounts"}, + ) + ) + + if "ssh_authorized_keys" in checks: + actions.append( + Action( + title="Review authorised SSH keys", + description=( + "Open ~/.ssh/authorized_keys and check every entry. Each line is " + "a key that can log in to this machine without your password.\n\n" + " 1. Remove any line you cannot account for.\n" + " 2. Compare the comment at the end of each line against the " + "machines you actually use.\n" + " 3. If you are unsure, remove them all and re-add the keys you " + "need — you will find out immediately what broke, and an " + "attacker's key will not be among what you re-add.\n\n" + "Also check ~/.ssh/config and any running remote-access tools; a " + "key is not the only way back in." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": "ssh_authorized_keys"}, + ) + ) + + return FixResult(module_name=self.name, actions=actions) + + # -- detection --------------------------------------------------------- + + def _browser_sessions(self, profile: SystemProfile) -> list[dict]: + """Count profile directories per browser. Cookie stores are never opened.""" + if self.browser_profile_roots is not None: + table = self.browser_profile_roots + elif profile.platform == Platform.DARWIN: + table = _DARWIN_BROWSER_PROFILE_ROOTS + else: + table = _WIN_BROWSER_PROFILE_ROOTS + + found = [] + for browser, raw_root in table.items(): + root = Path(raw_root).expanduser() + if not is_dir_nofollow(root): + continue + count = self._count_profiles(browser, root) + if count: + found.append({"browser": browser, "profiles": count}) + return sorted(found, key=lambda b: b["browser"]) + + @staticmethod + def _count_profiles(browser: str, root: Path) -> int: + """Chromium keeps 'Default' and 'Profile N' dirs; others are one profile.""" + try: + entries = list(root.iterdir()) + except OSError: + return 0 + + if browser == "Safari": + return 1 + + named = [ + e + for e in entries + if is_dir_nofollow(e) + and (e.name == "Default" or e.name.startswith("Profile ")) + ] + if named: + return len(named) + # Firefox profile dirs are "."; anything else with content + # counts as a single profile. + firefox_like = [e for e in entries if is_dir_nofollow(e) and "." in e.name] + return len(firefox_like) if firefox_like else 1 + + def _system_accounts(self, profile: SystemProfile) -> list[str]: + """List configured account identifiers. No token or password is read.""" + if profile.platform == Platform.DARWIN: + result = run( + ["defaults", "read", "MobileMeAccounts", "Accounts"], + timeout=_COMMAND_TIMEOUT, + ) + if not result.ok: + return [] + accounts = [] + for line in result.stdout.splitlines(): + stripped = line.strip() + if stripped.startswith("AccountID"): + parts = stripped.split("=", 1) + if len(parts) == 2: + accounts.append(parts[1].strip().strip(';" ')) + return sorted(set(a for a in accounts if a)) + + result = run( + ["cmdkey", "/list"], + timeout=_COMMAND_TIMEOUT, + ) + if not result.ok: + return [] + targets = [] + for line in result.stdout.splitlines(): + stripped = line.strip() + if stripped.lower().startswith("target:"): + targets.append(stripped.split(":", 1)[1].strip()) + return sorted(set(t for t in targets if t)) + + def _authorized_keys_path(self) -> Path: + if self.authorized_keys_path is not None: + return Path(self.authorized_keys_path) + return Path(_SSH_AUTHORIZED_KEYS).expanduser() + + def _authorized_keys_count(self) -> int: + """Count authorised entries without reading key material. + + Lines are counted, never returned. Reading is bounded so a pathological + file cannot stall the scan. + """ + path = self._authorized_keys_path() + if not is_file_nofollow(path): + return 0 + count = 0 + try: + with open(path, "r", errors="replace") as handle: + for index, line in enumerate(handle): + if index >= _MAX_AUTHORIZED_KEYS_LINES: + break + stripped = line.strip() + if stripped and not stripped.startswith("#"): + count += 1 + except OSError: + return 0 + return count diff --git a/modules/security/stalkerware_scan/__init__.py b/modules/security/stalkerware_scan/__init__.py new file mode 100644 index 0000000..33a0787 --- /dev/null +++ b/modules/security/stalkerware_scan/__init__.py @@ -0,0 +1,416 @@ +"""Look for software installed to watch the person using this computer. + +There are three overlapping things here and they need to be told apart, not +lumped together: + +- Stalkerware: sold for covertly monitoring another adult. Its presence on a + personal machine is the finding. +- Workplace and parental monitoring: legitimate products in the setting they + were designed for, routinely repurposed to control a partner or an adult + family member. Reported with the context needed to judge it. +- Remote access tools: ordinary IT software that happens to give someone else + full control of the machine. Reported so it can be accounted for, not + because it is malicious. + +The safety guidance in ``fix()`` matters as much as the detection. For someone +being monitored by a person they live with, removing the software is not +automatically the right first move: it tells the person watching that they +have been found out. That warning comes before any removal instruction. +""" + +import json +import re +import subprocess +from pathlib import Path + +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase +from rescue.runtime import content_file + +DATA_FILE = content_file("modules/security/stalkerware_scan/data/known_stalkerware.json") + +_COMMAND_TIMEOUT = 20 +_MAX_FILES_PER_LOCATION = 300 + +_SEVERITY_BY_NAME = { + "critical": Severity.CRITICAL, + "warning": Severity.WARNING, + "info": Severity.INFO, +} + +_DARWIN_APP_DIRS = [ + "/Applications", + "~/Applications", +] + +_DARWIN_PERSISTENCE_DIRS = [ + "~/Library/LaunchAgents", + "/Library/LaunchAgents", + "/Library/LaunchDaemons", +] + +_WIN_UNINSTALL_KEYS = [ + r"HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall", + r"HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall", +] + +_SAFETY_WARNING = ( + "Before removing anything: if the person who may have installed this lives " + "with you, or has ever frightened you, removing it can be dangerous. " + "Monitoring software usually tells whoever set it up when it stops " + "reporting, and that can escalate the situation. Talk to a domestic abuse " + "advocate first — they can help you plan the order in which to do this " + "safely.\n\n" + " US: National Domestic Violence Hotline, 1-800-799-7233, or text START " + "to 88788 (thehotline.org)\n" + " US: NNEDV Safety Net, techsafety.org — specialists in " + "technology-facilitated abuse\n" + " UK: National Domestic Abuse Helpline, 0808 2000 247; tech abuse support " + "at refugetechsafety.org\n" + " Australia: 1800RESPECT, 1800 737 732\n" + " Elsewhere: the Coalition Against Stalkerware (stopstalkerware.org) " + "lists services by country\n\n" + "Do not look these up from this device if you can avoid it — browsing " + "history is one of the things monitoring software reports." +) + + +class Module(ModuleBase): + name = "stalkerware_scan" + category = "security" + platforms = [Platform.DARWIN, Platform.WIN32, Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 82 + depends_on = [] + estimated_duration = "20s" + + def check(self, profile: SystemProfile) -> CheckResult: + entries = _load_known_stalkerware() + if not entries: + return CheckResult( + module_name=self.name, + error="Stalkerware indicator data could not be loaded.", + ) + + platform = profile.platform.value + applicable = [e for e in entries if platform in e.get("platforms", [])] + + observations: list[tuple[dict, str, str]] = [] + observations.extend(self._scan_processes(profile, applicable)) + observations.extend(self._scan_installed_software(profile, applicable)) + + if profile.platform == Platform.DARWIN: + observations.extend(self._scan_darwin_paths(applicable)) + elif profile.platform == Platform.WIN32: + observations.extend(self._scan_windows_uninstall_entries(applicable)) + + return CheckResult( + module_name=self.name, findings=self._to_findings(observations) + ) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + """Guidance only, and safety guidance first.""" + actions: list[Action] = [] + if not findings.findings: + return FixResult(module_name=self.name, actions=actions) + + categories = {f.data.get("stalkerware_category") for f in findings.findings} + + if "stalkerware" in categories: + actions.append( + Action( + title="Read this before removing the monitoring software", + description=_SAFETY_WARNING, + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + actions.append( + Action( + title="Use a different device for anything sensitive", + description=( + "Assume everything typed on this computer — passwords, " + "messages, searches, plans — has been seen. Until it is " + "cleaned up, use a device the other person has never had " + "physical access to, on a network they do not control, for " + "anything that matters. Do not change your important passwords " + "from this computer: the monitoring software would capture the " + "new ones as you type them." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + actions.append( + Action( + title="Preserve evidence before you delete anything", + description=( + "Monitoring another adult without consent is a crime in many " + "places, and the installed software is the evidence. " + "Photograph the screen showing the software, note the date, " + "and keep this scan's output. Deleting first leaves nothing to " + "show a police officer, a lawyer, or a judge." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + ) + ) + + for finding in findings.findings: + name = finding.data.get("stalkerware_name", "the software") + category = finding.data.get("stalkerware_category") + location = finding.data.get("location", "this machine") + + if category == "stalkerware": + actions.append( + Action( + title=f"Remove {name} when it is safe to do so", + description=( + f"{name} was found at {location}. When you have a safety " + "plan in place:\n" + "1. Uninstall it through the normal uninstaller if one " + "exists — monitoring software often reinstalls itself if " + "only the visible files are deleted.\n" + "2. Check for a second copy: these products commonly " + "install both a background service and a startup entry.\n" + "3. Change every account password afterwards, from a " + "different device.\n" + "4. Turn on two-factor authentication for email first, " + "then everything else.\n" + "5. The most reliable cleanup for a machine that has been " + "monitored is a full operating-system reinstall. Consider " + "it if anything about the machine still feels wrong." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + data={"software": name}, + ) + ) + elif category in {"employee_monitoring", "parental_control"}: + actions.append( + Action( + title=f"Decide whether {name} belongs on this computer", + description=( + f"{name} was found at {location}. This is a legitimate " + "product, so the question is not whether it is malware but " + "whether it is supposed to be here:\n" + "- On a computer owned by an employer or school, it is " + "expected. It still means work can see activity on it, so " + "keep personal accounts off this machine.\n" + "- On a personal computer nobody told you about, someone " + "set it up to watch what you do. Treat it the same way as " + "any other monitoring software." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"software": name}, + ) + ) + else: + actions.append( + Action( + title=f"Account for the remote access tool {name}", + description=( + f"{name} was found at {location}. Remote access software " + "lets someone else see and control this screen. Check:\n" + "- Did you or someone you trust install it, and is it still " + "needed? If not, uninstall it.\n" + "- If it stays, open its settings and check whether " + "unattended access is enabled and which accounts or " + "devices are on its allow list. Remove anything you do not " + "recognise, and change its password.\n" + "- Software left behind after a 'tech support' phone call " + "should be removed, and the accounts used on this machine " + "should have their passwords changed." + ), + risk_level=RiskLevel.MODERATE, + kind=ActionKind.GUIDANCE, + success=True, + data={"software": name}, + ) + ) + + return FixResult(module_name=self.name, actions=actions) + + # ---------------- scanners ---------------- + + def _scan_processes( + self, profile: SystemProfile, entries: list[dict] + ) -> list[tuple[dict, str, str]]: + observations = [] + for proc in profile.processes: + haystack = f"{proc.name} {proc.command}".lower() + entry = _match(haystack, entries) + if entry is not None: + observations.append( + (entry, "running process", f"{proc.name} (pid {proc.pid})") + ) + return observations + + def _scan_installed_software( + self, profile: SystemProfile, entries: list[dict] + ) -> list[tuple[dict, str, str]]: + observations = [] + for software in profile.installed_software: + entry = _match(software.lower(), entries) + if entry is not None: + observations.append((entry, "installed software", software)) + return observations + + def _scan_darwin_paths(self, entries: list[dict]) -> list[tuple[dict, str, str]]: + observations = [] + for directory in _DARWIN_APP_DIRS: + for path in _bounded_entries(directory): + entry = _match(path.name.lower(), entries) + if entry is not None: + observations.append((entry, "installed application", str(path))) + for directory in _DARWIN_PERSISTENCE_DIRS: + for path in _bounded_entries(directory): + if path.suffix != ".plist": + continue + entry = _match(path.name.lower(), entries) + if entry is not None: + observations.append((entry, "startup item", str(path))) + return observations + + def _scan_windows_uninstall_entries( + self, entries: list[dict] + ) -> list[tuple[dict, str, str]]: + observations = [] + for key in _WIN_UNINSTALL_KEYS: + output = _run(["reg", "query", key, "/s", "/v", "DisplayName"]) + for line in output.splitlines(): + if "DisplayName" not in line: + continue + parts = line.split("REG_SZ") + display_name = parts[-1].strip() if len(parts) > 1 else "" + if not display_name: + continue + entry = _match(display_name.lower(), entries) + if entry is not None: + observations.append((entry, "installed program", display_name)) + return observations + + # ---------------- findings ---------------- + + def _to_findings( + self, observations: list[tuple[dict, str, str]] + ) -> list[Finding]: + findings: list[Finding] = [] + seen: set[tuple[str, str]] = set() + + for entry, where, location in observations: + key = (entry["name"], location) + if key in seen: + continue + seen.add(key) + + category = entry.get("category", "stalkerware") + severity = _SEVERITY_BY_NAME.get( + entry.get("severity", "warning"), Severity.WARNING + ) + findings.append( + Finding( + title=f"{entry['name']} found as {where}", + description=( + f"{entry['description']}\n\n" + f"Found as {where}: {location}." + + ( + "\n\nThis product is sold for monitoring another person " + "without their knowledge. If nobody told you it was here, " + "someone installed it to watch what you do on this " + "computer." + if category == "stalkerware" + else "" + ) + ), + severity=severity, + category=self.category, + data={ + "check": "monitoring_software_found", + "stalkerware_name": entry["name"], + "stalkerware_category": category, + "detected_as": where, + "location": location, + "confidence": "high" if category == "stalkerware" else "medium", + }, + ) + ) + return findings + + +# ---------------- helpers ---------------- + + +def _load_known_stalkerware() -> list[dict]: + try: + with open(DATA_FILE) as f: + data = json.load(f) + except (OSError, ValueError): + return [] + entries = data.get("entries", []) + return entries if isinstance(entries, list) else [] + + +def _match(haystack: str, entries: list[dict]) -> dict | None: + for entry in entries: + if _pattern_regex(entry["pattern"]).search(haystack) is not None: + return entry + return None + + +def _pattern_regex(pattern: str) -> re.Pattern: + """Compile a product name into a boundary-aware matcher. + + Several product names are short, ordinary words ("bark", "aobo"), so plain + substring matching would accuse people over filenames like "barkeeper.app". + Requiring a non-alphanumeric character on both sides keeps punctuation and + path separators as boundaries while rejecting matches inside longer words. + """ + cached = _PATTERN_CACHE.get(pattern) + if cached is None: + cached = re.compile( + rf"(? list[Path]: + try: + base = Path(directory).expanduser() + if not base.is_dir(): + return [] + return sorted(base.iterdir())[:_MAX_FILES_PER_LOCATION] + except OSError: + return [] + + +def _run(command: list[str]) -> str: + try: + result = subprocess.run( + command, capture_output=True, text=True, timeout=_COMMAND_TIMEOUT + ) + except (OSError, subprocess.SubprocessError): + return "" + return result.stdout or "" diff --git a/modules/security/stalkerware_scan/data/known_stalkerware.json b/modules/security/stalkerware_scan/data/known_stalkerware.json new file mode 100644 index 0000000..2b8db24 --- /dev/null +++ b/modules/security/stalkerware_scan/data/known_stalkerware.json @@ -0,0 +1,391 @@ +{ + "version": "1.0.0", + "last_updated": "2026-08-03", + "note": "Categories matter more than the raw list. 'stalkerware' is software sold for covert monitoring of another adult; 'employee_monitoring' and 'parental_control' are legitimate products that are routinely repurposed for abuse; 'remote_access' tools are ordinary IT software that gives someone else full control of the machine. Only the first category is inherently a finding — the rest are reported with the context needed to judge them.", + "entries": [ + { + "pattern": "flexispy", + "name": "FlexiSPY", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32", "linux"], + "description": "Covert monitoring software that captures messages, calls, location, and ambient audio, and is marketed explicitly for monitoring partners." + }, + { + "pattern": "mspy", + "name": "mSpy", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring suite that records keystrokes, messages, browsing, and location, and hides itself from the person being monitored." + }, + { + "pattern": "hoverwatch", + "name": "Hoverwatch", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Hidden monitoring software that logs keystrokes, screenshots, and webcam images without notifying the user." + }, + { + "pattern": "cocospy", + "name": "Cocospy", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring service marketed for tracking partners and family members." + }, + { + "pattern": "umobix", + "name": "uMobix", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring service capturing messages, calls, and screen activity." + }, + { + "pattern": "spyzie", + "name": "Spyzie", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring service marketed for silent tracking of another person's device." + }, + { + "pattern": "thetruthspy", + "name": "TheTruthSpy", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring service; its own servers have leaked victim data repeatedly." + }, + { + "pattern": "xnspy", + "name": "XNSPY", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring suite advertised for partner and employee surveillance." + }, + { + "pattern": "ikeymonitor", + "name": "iKeyMonitor", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Keylogger and screen recorder marketed for covert monitoring." + }, + { + "pattern": "clevguard", + "name": "ClevGuard / KidsGuard", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "KidsGuard Pro records keystrokes, screenshots, and messages while hiding its presence." + }, + { + "pattern": "kidsguard", + "name": "KidsGuard Pro", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring product that hides its icon and runs invisibly." + }, + { + "pattern": "spyrix", + "name": "Spyrix", + "category": "stalkerware", + "severity": "critical", + "platforms": ["win32"], + "description": "Keylogger that streams keystrokes, screenshots, and webcam captures to a remote dashboard." + }, + { + "pattern": "refog", + "name": "Refog Personal Monitor", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Hidden keylogger and screen recorder sold for monitoring family members." + }, + { + "pattern": "ardamax", + "name": "Ardamax Keylogger", + "category": "stalkerware", + "severity": "critical", + "platforms": ["win32"], + "description": "Stealth keylogger that runs invisibly and emails captured keystrokes." + }, + { + "pattern": "spyagent", + "name": "Spytech SpyAgent", + "category": "stalkerware", + "severity": "critical", + "platforms": ["win32"], + "description": "Stealth monitoring suite recording keystrokes, screenshots, and application use." + }, + { + "pattern": "realtime-spy", + "name": "Realtime-Spy", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Remote monitoring product that installs silently and reports to a web dashboard." + }, + { + "pattern": "pctattletale", + "name": "PC Tattletale", + "category": "stalkerware", + "severity": "critical", + "platforms": ["win32"], + "description": "Screen recording monitoring product; its recordings have been exposed publicly by its own misconfiguration." + }, + { + "pattern": "webwatcher", + "name": "WebWatcher", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Covert monitoring product recording messages, browsing, and screenshots." + }, + { + "pattern": "elite keylogger", + "name": "Elite Keylogger", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Stealth keylogger that hides from the process list and task manager." + }, + { + "pattern": "perfect keylogger", + "name": "Perfect Keylogger", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Stealth keylogger with remote log delivery." + }, + { + "pattern": "aobo", + "name": "Aobo Keylogger", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32"], + "description": "Invisible keylogger for macOS and Windows sold for covert monitoring." + }, + { + "pattern": "kidlogger", + "name": "KidLogger", + "category": "stalkerware", + "severity": "critical", + "platforms": ["darwin", "win32", "linux"], + "description": "Monitoring software that records keystrokes, applications, and screenshots in the background." + }, + { + "pattern": "actual keylogger", + "name": "Actual Keylogger", + "category": "stalkerware", + "severity": "critical", + "platforms": ["win32"], + "description": "Hidden keystroke recorder." + }, + { + "pattern": "teramind", + "name": "Teramind", + "category": "employee_monitoring", + "severity": "warning", + "platforms": ["darwin", "win32", "linux"], + "description": "Workplace monitoring agent recording screen, keystrokes, and application use. Expected on a company-managed computer; on a personal machine it means someone is watching everything done on it." + }, + { + "pattern": "activtrak", + "name": "ActivTrak", + "category": "employee_monitoring", + "severity": "warning", + "platforms": ["darwin", "win32"], + "description": "Workplace activity monitoring agent capturing screenshots and application use." + }, + { + "pattern": "veriato", + "name": "Veriato / Cerberus", + "category": "employee_monitoring", + "severity": "warning", + "platforms": ["darwin", "win32"], + "description": "Employee monitoring suite with covert recording modes." + }, + { + "pattern": "interguard", + "name": "InterGuard", + "category": "employee_monitoring", + "severity": "warning", + "platforms": ["darwin", "win32"], + "description": "Employee monitoring agent that can run invisibly to the user." + }, + { + "pattern": "staffcop", + "name": "StaffCop", + "category": "employee_monitoring", + "severity": "warning", + "platforms": ["win32", "linux"], + "description": "Employee monitoring suite recording screen, keystrokes, and files." + }, + { + "pattern": "kickidler", + "name": "Kickidler", + "category": "employee_monitoring", + "severity": "warning", + "platforms": ["darwin", "win32", "linux"], + "description": "Employee monitoring agent with continuous screen recording." + }, + { + "pattern": "hubstaff", + "name": "Hubstaff", + "category": "employee_monitoring", + "severity": "info", + "platforms": ["darwin", "win32", "linux"], + "description": "Time-tracking agent that can capture periodic screenshots. Normal on a work machine when the person knows it is installed." + }, + { + "pattern": "timedoctor", + "name": "Time Doctor", + "category": "employee_monitoring", + "severity": "info", + "platforms": ["darwin", "win32", "linux"], + "description": "Time-tracking agent with optional screenshot capture." + }, + { + "pattern": "qustodio", + "name": "Qustodio", + "category": "parental_control", + "severity": "info", + "platforms": ["darwin", "win32"], + "description": "Parental control software. Legitimate for a child's device; on an adult's own computer it gives whoever set it up a log of their activity." + }, + { + "pattern": "net nanny", + "name": "Net Nanny", + "category": "parental_control", + "severity": "info", + "platforms": ["darwin", "win32"], + "description": "Parental control and content filtering software with activity reporting." + }, + { + "pattern": "bark", + "name": "Bark", + "category": "parental_control", + "severity": "info", + "platforms": ["darwin", "win32"], + "description": "Parental monitoring service that reports messages and browsing to an account owner." + }, + { + "pattern": "teamviewer", + "name": "TeamViewer", + "category": "remote_access", + "severity": "info", + "platforms": ["darwin", "win32", "linux"], + "description": "Remote control software. Useful and common — and it gives whoever holds the credentials complete control of the machine, including watching the screen. Check whether unattended access is enabled and who is on its trusted-devices list." + }, + { + "pattern": "anydesk", + "name": "AnyDesk", + "category": "remote_access", + "severity": "info", + "platforms": ["darwin", "win32", "linux"], + "description": "Remote control software frequently installed by tech-support scammers and left behind afterwards." + }, + { + "pattern": "screenconnect", + "name": "ScreenConnect / ConnectWise Control", + "category": "remote_access", + "severity": "warning", + "platforms": ["darwin", "win32", "linux"], + "description": "Remote support agent very commonly abused for persistent unauthorised access, because it installs as a service and reconnects on its own." + }, + { + "pattern": "netsupport", + "name": "NetSupport Manager", + "category": "remote_access", + "severity": "warning", + "platforms": ["darwin", "win32"], + "description": "Remote control product routinely repackaged as malware ('NetSupport RAT') and installed silently." + }, + { + "pattern": "remoteutilities", + "name": "Remote Utilities", + "category": "remote_access", + "severity": "warning", + "platforms": ["win32"], + "description": "Remote access product commonly used for persistent covert access." + }, + { + "pattern": "ammyy", + "name": "Ammyy Admin", + "category": "remote_access", + "severity": "warning", + "platforms": ["win32"], + "description": "Remote access tool strongly associated with support scams and unauthorised access." + }, + { + "pattern": "dwagent", + "name": "DWAgent", + "category": "remote_access", + "severity": "warning", + "platforms": ["darwin", "win32", "linux"], + "description": "Unattended remote access agent that runs as a service." + }, + { + "pattern": "logmein", + "name": "LogMeIn", + "category": "remote_access", + "severity": "info", + "platforms": ["darwin", "win32"], + "description": "Unattended remote access service." + }, + { + "pattern": "splashtop", + "name": "Splashtop", + "category": "remote_access", + "severity": "info", + "platforms": ["darwin", "win32", "linux"], + "description": "Remote desktop service with unattended access." + }, + { + "pattern": "realvnc", + "name": "RealVNC", + "category": "remote_access", + "severity": "info", + "platforms": ["darwin", "win32", "linux"], + "description": "Remote screen access server." + }, + { + "pattern": "tightvnc", + "name": "TightVNC", + "category": "remote_access", + "severity": "warning", + "platforms": ["win32", "linux"], + "description": "VNC server, often installed silently to provide hidden screen access." + }, + { + "pattern": "ultravnc", + "name": "UltraVNC", + "category": "remote_access", + "severity": "warning", + "platforms": ["win32"], + "description": "VNC server, often installed silently to provide hidden screen access." + }, + { + "pattern": "radmin", + "name": "Radmin", + "category": "remote_access", + "severity": "warning", + "platforms": ["win32"], + "description": "Remote administration server providing full control of the machine." + }, + { + "pattern": "atera", + "name": "Atera agent", + "category": "remote_access", + "severity": "warning", + "platforms": ["darwin", "win32"], + "description": "Remote management agent; abused by intruders because it is signed, silent, and grants full remote control." + } + ] +} diff --git a/modules/security/twofa_audit/__init__.py b/modules/security/twofa_audit/__init__.py new file mode 100644 index 0000000..f22efc2 --- /dev/null +++ b/modules/security/twofa_audit/__init__.py @@ -0,0 +1,385 @@ +"""Check what two-factor authentication capability exists on this device. + +The roadmap named ``twofa_audit`` for the digital security reset and it was +never built. This is the real thing, with one deliberate limitation stated up +front: **a local tool cannot tell you whether 2FA is enabled on your accounts.** +That state lives at Google, at your bank, at your email provider. Finding out +requires logging in, and this tool will never ask for a credential. + +So this module answers the question it actually can answer — "is this device +equipped to do 2FA, and does it show any local sign of platform 2FA?" — and says +plainly that account-level status was not checked. An unverified security +verdict presented as fact is worse than an admitted gap, especially for someone +working through a compromise who needs to know what has genuinely been +confirmed. + +Concretely it reports: + +- Authenticator apps installed on this machine. +- Platform 2FA signals that *are* locally observable: on macOS, whether the + signed-in Apple ID advertises two-factor in its account preferences; on + Windows, whether Windows Hello / a PIN is configured for the local account. +- A WARNING when neither an authenticator app nor any platform signal exists, + because that combination means there is probably no second factor anywhere. +""" + +import plistlib +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.command import run +from rescue.fsbounds import is_dir_nofollow + +_COMMAND_TIMEOUT = 20 + +# Applications that generate or hold TOTP codes / manage hardware keys. +# More specific names first: matching is by prefix and first match wins. +_AUTHENTICATORS = [ + {"name": "1Password", "app": ["1Password"], "windows": ["1Password"]}, + {"name": "Microsoft Authenticator", "app": ["Microsoft Authenticator"], "windows": ["Microsoft Authenticator"]}, + {"name": "Authy", "app": ["Authy"], "windows": ["Authy"]}, + {"name": "Bitwarden", "app": ["Bitwarden"], "windows": ["Bitwarden"]}, + {"name": "Ente Auth", "app": ["Ente Auth", "Auth"], "windows": ["Ente Auth"]}, + {"name": "Raivo OTP", "app": ["Raivo"], "windows": []}, + {"name": "Step Two", "app": ["Step Two"], "windows": []}, + {"name": "KeePassXC", "app": ["KeePassXC"], "windows": ["KeePassXC"]}, + {"name": "YubiKey Manager", "app": ["YubiKey Manager", "ykman"], "windows": ["YubiKey Manager"]}, + {"name": "OTP Auth", "app": ["OTP Auth"], "windows": []}, +] + +_DARWIN_APP_DIRS = ["/Applications", "~/Applications"] + +_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", +] + +_ACCOUNT_SCOPE_CAVEAT = ( + "This check looked only at this device. Whether two-factor authentication is " + "actually switched on for your email, bank, or other accounts is stored by " + "those providers, and confirming it requires signing in — which this tool " + "will never do and never asks you to do here. Treat the account-level status " + "as NOT CHECKED." +) + + +class Module(ModuleBase): + name = "twofa_audit" + category = "security" + platforms = [Platform.DARWIN, Platform.WIN32] + risk_level = RiskLevel.SAFE + priority = 79 + depends_on = [] + estimated_duration = "10s" + + emits_codes = [ + "security.twofa_audit.no_second_factor_capability", + "security.twofa_audit.platform_2fa_unknown", + "security.twofa_audit.inventory", + ] + + # Read roots, overridable so tests never depend on the host machine. + app_dirs: list[str] | None = None + mobileme_plist_path: Path | None = None + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform not in (Platform.DARWIN, Platform.WIN32): + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "Two-factor capability detection is implemented for macOS and " + f"Windows; this host reports {profile.platform.value}." + ), + ) + + findings: list[Finding] = [] + + if profile.platform == Platform.DARWIN: + authenticators = self._find_darwin_authenticators() + platform_2fa, platform_detail = self._darwin_platform_2fa() + else: + authenticators = self._find_windows_authenticators() + platform_2fa, platform_detail = self._windows_platform_2fa() + + if not authenticators and platform_2fa is not True: + findings.append( + Finding( + title="No second-factor capability found on this device", + description=( + "No authenticator app is installed and no platform two-factor " + "signal was found on this machine.\n\n" + "That does not prove your accounts are unprotected — you may use " + "an authenticator on your phone, or a hardware key. But if you " + "do not, a stolen password is enough to take over an account, and " + "that is the single most common way accounts are lost.\n\n" + + _ACCOUNT_SCOPE_CAVEAT + ), + severity=Severity.WARNING, + category=self.category, + code="security.twofa_audit.no_second_factor_capability", + data={ + "check": "no_second_factor_capability", + "authenticators": authenticators, + "platform_2fa": platform_2fa, + }, + ) + ) + + if platform_2fa is None: + findings.append( + Finding( + title="Platform two-factor status could not be determined", + description=( + f"{platform_detail}\n\n" + "This is reported as unknown rather than guessed. A security " + "check that reports a state it did not actually observe is worse " + "than one that admits the gap." + ), + severity=Severity.INFO, + category=self.category, + code="security.twofa_audit.platform_2fa_unknown", + data={ + "check": "platform_2fa_unknown", + "detail": platform_detail, + }, + ) + ) + + findings.append( + Finding( + title=( + f"Two-factor capability: {len(authenticators)} authenticator app(s)" + ), + description=( + "Authenticator apps found on this device: " + + (", ".join(authenticators) if authenticators else "none") + + f"\nPlatform two-factor signal: {platform_detail}" + + "\n\n" + + _ACCOUNT_SCOPE_CAVEAT + ), + severity=Severity.INFO, + category=self.category, + code="security.twofa_audit.inventory", + data={ + "check": "inventory", + "authenticators": authenticators, + "platform_2fa": platform_2fa, + "platform_detail": platform_detail, + "account_status_checked": False, + }, + ) + ) + + return CheckResult(module_name=self.name, findings=findings) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + + for finding in findings.findings: + check = finding.data.get("check") + + if check == "no_second_factor_capability": + actions.append( + Action( + title="Turn on two-factor authentication, highest-value accounts first", + description=( + "Order matters. Do them in this sequence, because each one " + "protects the next:\n\n" + " 1. Your email account. Everything else resets through it, " + "so it is the account an attacker wants most.\n" + " 2. Your password manager.\n" + " 3. Banking and payment accounts.\n" + " 4. Everything else that offers it.\n\n" + "Prefer, in order: a hardware security key (strongest, and " + "phishing-resistant), then an authenticator app, then SMS. " + "SMS is genuinely better than nothing, but it is defeated by " + "a SIM swap, so do not stop there for your email or bank.\n\n" + "Save each account's recovery codes when you set it up, on " + "paper or in your password manager — not in the authenticator " + "app itself, or losing the phone locks you out permanently.\n\n" + "Never type a one-time code or a recovery code into this tool. " + "Nothing legitimate will ever ask you to." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": check}, + ) + ) + + elif check == "platform_2fa_unknown": + actions.append( + Action( + title="Confirm platform two-factor status yourself", + description=( + f"{finding.data.get('detail', '')}\n\n" + "Check it directly:\n" + " macOS: System Settings > [your name] > Sign-In & Security > " + "Two-Factor Authentication\n" + " Windows: Settings > Accounts > Sign-in options, and your " + "Microsoft account security page\n\n" + "This tool deliberately does not sign in to find out." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + success=True, + data={"check": check}, + ) + ) + + return FixResult(module_name=self.name, actions=actions) + + # -- detection --------------------------------------------------------- + + def _app_dirs(self) -> list[str]: + return self.app_dirs if self.app_dirs is not None else _DARWIN_APP_DIRS + + def _find_darwin_authenticators(self) -> list[str]: + found: set[str] = set() + for raw_dir in self._app_dirs(): + directory = Path(raw_dir).expanduser() + if not is_dir_nofollow(directory): + continue + try: + entries = list(directory.iterdir()) + except OSError: + continue + for entry in entries: + if entry.suffix != ".app": + continue + bundle = entry.stem.lower() + for app in _AUTHENTICATORS: + if any(bundle.startswith(a.lower()) for a in app["app"]): + found.add(app["name"]) + break + return sorted(found) + + def _find_windows_authenticators(self) -> list[str]: + found: set[str] = set() + for key in _WIN_UNINSTALL_KEYS: + result = run( + ["reg", "query", key, "/s", "/v", "DisplayName"], + timeout=_COMMAND_TIMEOUT, + ) + if not result.ok: + continue + for line in result.stdout.splitlines(): + if "DisplayName" not in line: + continue + parts = line.split("REG_SZ") + if len(parts) < 2: + continue + display = parts[1].strip().lower() + for app in _AUTHENTICATORS: + if any(display.startswith(w.lower()) for w in app["windows"]): + found.add(app["name"]) + break + return sorted(found) + + def _mobileme_plist(self) -> Path: + if self.mobileme_plist_path is not None: + return Path(self.mobileme_plist_path) + return Path.home() / "Library/Preferences/MobileMeAccounts.plist" + + def _darwin_platform_2fa(self) -> tuple[bool | None, str]: + """Read the signed-in Apple ID's advertised 2FA state, or admit we can't. + + MobileMeAccounts.plist records whether the signed-in account is a + two-factor account. It is not authoritative for the Apple ID itself and + it says nothing about any other account, so an absent or unreadable + plist returns None ("unknown"), never False. + """ + path = self._mobileme_plist() + if not path.exists(): + return ( + None, + "No signed-in Apple ID was found on this Mac, so there is no local " + "record of its two-factor state.", + ) + try: + with open(path, "rb") as handle: + plist = plistlib.load(handle) + except (OSError, ValueError, plistlib.InvalidFileException): + return ( + None, + "The Apple ID account preferences could not be read, so the " + "two-factor state was not determined.", + ) + + accounts = plist.get("Accounts") or [] + if not accounts: + return ( + None, + "No Apple ID account entry is present, so there is no local record " + "of its two-factor state.", + ) + + for account in accounts: + if not isinstance(account, dict): + continue + if account.get("SecureAccount") is True or account.get("isTwoFactor") is True: + return ( + True, + "The signed-in Apple ID is recorded locally as a two-factor account.", + ) + + return ( + None, + "The signed-in Apple ID does not record a two-factor flag in local " + "preferences. Apple does not reliably expose this offline, so this is " + "reported as unknown rather than as disabled.", + ) + + def _windows_platform_2fa(self) -> tuple[bool | None, str]: + """Detect a configured Windows Hello / PIN credential provider. + + Presence of an NGC container for the account means a Hello credential + (PIN, face, or fingerprint) is enrolled. Absence of the key is not proof + of absence of 2FA, so a failed query returns None. + """ + result = run( + [ + "reg", + "query", + r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon", + "/v", + "EnableFirstLogonAnimation", + ], + timeout=_COMMAND_TIMEOUT, + ) + ngc = run( + ["reg", "query", r"HKLM\SOFTWARE\Policies\Microsoft\PassportForWork"], + timeout=_COMMAND_TIMEOUT, + ) + if ngc.ok and ngc.stdout.strip(): + return ( + True, + "Windows Hello (Passport for Work) policy is present, indicating a " + "Hello credential is configured on this device.", + ) + if not result.ok and not ngc.ok: + return ( + None, + "The Windows sign-in configuration could not be queried, so the " + "Windows Hello state was not determined.", + ) + return ( + None, + "No Windows Hello policy was found. Windows does not expose per-account " + "Hello enrolment to a read-only query, so this is reported as unknown " + "rather than as disabled.", + ) 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 cf53832..f50dace 100644 --- a/modules/security/win_malware_indicators/__init__.py +++ b/modules/security/win_malware_indicators/__init__.py @@ -76,13 +76,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/digital_security_reset.yaml b/profiles/digital_security_reset.yaml index ae9cc2d..a4d274a 100644 --- a/profiles/digital_security_reset.yaml +++ b/profiles/digital_security_reset.yaml @@ -3,14 +3,22 @@ display_name: "Digital Security Reset" description: > Post-compromise recovery for someone who has been hacked or suspects their accounts or device have been compromised. Runs read-only device - checks (malware indicators, suspicious processes, remote logins, network - connections, browser extensions, app permissions, sharing services), - then guides through the six-phase recovery process: grounding, reality - check, immediate protective actions, systematic cleanup, rebuilding - security, and mental health maintenance. Account, password-manager, 2FA, - and session steps are human-led and covered in the guide, not automated. + checks (evidence preservation readiness, malware indicators, suspicious + processes, remote logins, network connections, browser extensions, app + permissions, sharing services, code-signature integrity), plus a + device-side review of password storage, two-factor capability, and the + sign-in sessions that survive a password change. Then guides through the + six-phase recovery process: grounding, reality check, immediate protective + actions, systematic cleanup, rebuilding security, and mental health + maintenance. The device checks report what is observable locally; actually + changing passwords, enabling 2FA, and revoking sessions happens at each + provider and stays human-led in the guide. This tool never asks for an + account password, a one-time code, or a recovery code. modules: include: + # Runs first: repairs destroy the record of what happened, so evidence + # readiness is assessed before anything else reports a problem to fix. + - evidence_bundle - malware_scan_indicators - suspicious_processes - remote_login_check @@ -18,6 +26,13 @@ modules: - browser_extension_audit - app_permissions - sharing_services + - code_signature_audit + # The device-side half of the account-recovery phases. These answer + # "is this device leaking credentials, and what is still logged in"; + # the account-side work remains human-led in the guide. + - password_manager_check + - twofa_audit + - session_revocation_scan exclude: [] module_config: malware_scan_indicators: diff --git a/profiles/home_network_intrusion.yaml b/profiles/home_network_intrusion.yaml new file mode 100644 index 0000000..d5c0681 --- /dev/null +++ b/profiles/home_network_intrusion.yaml @@ -0,0 +1,40 @@ +name: home_network_intrusion +display_name: "Home Network Intrusion & Cryptojacking Response" +description: > + Response for a household whose Wi-Fi has been broken into, and for the + cryptojacking and monitoring software that tends to arrive with it. + Inventories what is actually on the local network, checks for traffic + interception, audits the router's exposed surface, and searches every + device for mining and monitoring software — including the startup + entries that quietly bring a miner back after it is killed. Ordered so + that the network is reclaimed before the devices are cleaned, because a + cleaned device rejoining a compromised network is compromised again. +modules: + include: + # The network: who is on it, is anyone intercepting, what is the router + # offering to whoever is connected. + - lan_device_inventory + - arp_spoof_check + - router_security_audit + - wifi_security_audit + # Cryptojacking: what is running now, what restarts it, and the browser. + - crypto_miner_detect + - win_crypto_miner_detect + - crypto_miner_persistence + - browser_cryptojacking_check + - process_scanner + # Who else has access to the machine itself. + - stalkerware_scan + - remote_login_check + - win_remote_access_audit + - suspicious_connections + - open_ports_scan + exclude: [] +module_config: + # Set expected_device_count to the number of devices the household can + # actually name — phones, laptops, TVs, speakers, consoles, printers, and + # every smart plug and doorbell. known_macs can list hardware addresses + # already accounted for, so the inventory only flags the rest. + lan_device_inventory: {} +guides: + - home_network_intrusion diff --git a/profiles/identity_theft_recovery.yaml b/profiles/identity_theft_recovery.yaml new file mode 100644 index 0000000..5c12ea7 --- /dev/null +++ b/profiles/identity_theft_recovery.yaml @@ -0,0 +1,35 @@ +name: identity_theft_recovery +display_name: "Identity Theft Recovery" +description: > + Step-by-step recovery for someone whose identity has been stolen: credit + and account freezes, the official reports that unlock legal protections, + disputing fraudulent accounts, and the long tail of monitoring afterwards. + Most of this work happens with banks, credit bureaus, and government + agencies rather than on the computer, so the guide is the substance and the + modules only answer one question — whether the device being used to do the + recovery is itself leaking credentials. The walkthrough doubles as a + checklist: run `rescue guide identity_theft_recovery` to see what is done + and what is outstanding, and mark steps off as you go. +modules: + include: + # Is this device the leak? Doing recovery from a compromised machine + # hands the new passwords straight back. + - stalkerware_scan + - keylogger_indicators + - malware_scan_indicators + - win_malware_indicators + - suspicious_processes + - win_suspicious_processes + - process_scanner + # Credential and session theft happens in the browser more often than + # anywhere else. + - browser_extension_audit + - browser_hijack_check + - certificate_trust_audit + # Somebody else still being logged in is the simplest explanation of all. + - remote_login_check + - win_remote_access_audit + exclude: [] +module_config: {} +guides: + - identity_theft_recovery diff --git a/rescue/fsbounds.py b/rescue/fsbounds.py index c08a2db..6e61a11 100644 --- a/rescue/fsbounds.py +++ b/rescue/fsbounds.py @@ -9,12 +9,40 @@ from __future__ import annotations import os +import stat import time from collections.abc import Iterator, Sequence from dataclasses import dataclass from pathlib import Path +def is_file_nofollow(path: Path | str) -> bool: + """True if ``path`` is a regular file, without following a final symlink. + + ``Path.is_file(follow_symlinks=False)`` only exists from Python 3.13, but + this package supports 3.11, where passing that keyword raises TypeError. + ``os.lstat`` gives the same no-follow semantics on every supported version. + Returns False rather than raising for a missing or unreadable path, matching + how ``Path.is_file`` swallows OSError. + """ + try: + return stat.S_ISREG(os.lstat(path).st_mode) + except (OSError, ValueError): + return False + + +def is_dir_nofollow(path: Path | str) -> bool: + """True if ``path`` is a directory, without following a final symlink. + + The no-follow counterpart of :func:`is_file_nofollow`; see its note on why + ``Path.is_dir(follow_symlinks=False)`` cannot be used here. + """ + try: + return stat.S_ISDIR(os.lstat(path).st_mode) + except (OSError, ValueError): + return False + + @dataclass class WalkLimits: """Hard bounds for a traversal. diff --git a/rescue/runtime.py b/rescue/runtime.py index 3fc44a7..b7ad9bd 100644 --- a/rescue/runtime.py +++ b/rescue/runtime.py @@ -2,10 +2,12 @@ from __future__ import annotations +import importlib.util import os import sys import sysconfig from pathlib import Path +from types import ModuleType ASSET_DIRECTORY_NAME = "multiverse-device-rescue" @@ -48,6 +50,40 @@ def content_directory(name: str) -> Path: return bundled_root() / name +def load_content_module(relative_path: str | Path, key: str) -> ModuleType | None: + """Import a helper shipped alongside the modules, by path. + + Modules ship as data files rather than as an importable package (see + `setup.py`), so a shared helper such as an IOC loader cannot be reached + with a normal `import`. Loading it by path works in a source checkout, a + pip install, and a PyInstaller bundle alike. + + Returns None when the helper is missing or fails to execute; every caller + is expected to degrade to "this check is unavailable" rather than raise. + """ + existing = sys.modules.get(key) + if existing is not None: + return existing + + path = content_file(relative_path) + try: + spec = importlib.util.spec_from_file_location(key, path) + if spec is None or spec.loader is None: + return None + module = importlib.util.module_from_spec(spec) + # Registered before execution: dataclasses defined in the helper look + # their own module up in sys.modules while being constructed. + sys.modules[key] = module + try: + spec.loader.exec_module(module) + except Exception: + del sys.modules[key] + raise + return module + except Exception: + return None + + def content_file(relative_path: str | Path) -> Path: """Resolve an updateable data file without allowing updated Python code.""" relative = Path(relative_path) diff --git a/rescue/security/integrity_manifest.json b/rescue/security/integrity_manifest.json index cb8a64c..2453aba 100644 --- a/rescue/security/integrity_manifest.json +++ b/rescue/security/integrity_manifest.json @@ -11,12 +11,12 @@ "ai/providers/ollama_provider.py": "eb7bb8a3f9a54c359b964d448917bb6c42f7de10234044fbd17ba8044e9ed2bd", "ai/providers/openai_provider.py": "8e53fc2a5aaf860aa6debe67d012327dc1f830a0fd32cf4c1b266172a384efcd", "ai/recommender.py": "4427a4340f37eb8963f6f6656eaeb44647d01fc5b8a8f7b1ce1c7a0a85fd2d27", - "cli.py": "454d449389e6b75472f3f36b58f56c3189e4d8b68e8866f7eb47b27754c2c74c", - "command.py": "2f7ff3c91f655f9eb10cfc5c0a223c42d131fcc33339c2351c34cd4653a48af0", - "fsbounds.py": "80bab900d75bd86a798a6b5d96389b1179870b8966973ecd544c2de9287ced4b", - "guides.py": "330aca3cbe5a6242706d4de0e656f1e0d377fd1c96c46c289d322c2b4e43db7a", - "models.py": "93f69006b92eeff54d0fd215c2697a8df8e89c9de7d14ee5b7a4a185e9c93916", - "module_base.py": "a2c7ba48f351a05fa28bdbde31fd6b3a94bb11cbaa5e11716e39b1279e2f34c9", + "cli.py": "c9ff51b97fdb8e00ed89c3dd5189f2185af545c58ad3c8bea184bb9c625a6eb9", + "command.py": "fa75b1df5b3b742f81e0e3117f628030523c9442c63aac5c747e99d3c632043e", + "fsbounds.py": "9eae7c588b5e31a373a42bc3d0fa029e3ac7df15f9d50409b61c2c80acbacdc8", + "guides.py": "324b103e3895bc353619521b7ee88c32e624535bc5de9ccab8d3a9b7b013d749", + "models.py": "386719057364450fe671c2f0f678328edead07848d676df43095f1d58a362946", + "module_base.py": "bc57a2b3a9c7a58d66980652e549c159464cca0d781b590c792f9aa0ad649439", "orchestrator.py": "b63a65ed4a4a40b83369dacf37200cf2e2e92b13a88710715509ef9728e64c28", "profiler/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "profiler/base.py": "f546999d835d55e00fa496c8f3ef4b3113e73ee57227de7399aa584d60c9993e", @@ -25,23 +25,28 @@ "profiler/windows.py": "b3fdae0d8671b5a669c0af05f5a50f7ffb1b61520f45280f82e16a0f96c83d45", "profiles.py": "4268b3e8595daf37970776e14e13790a3966fd55fc0caca01aa11d9dda3e939a", "registry.py": "df8f118c46f109754a2579a4fbda91ba7b28d0b189dc3db5f66620f8e2c3cebd", - "runtime.py": "1e7afa49e96cdd648cb4467f5d87387320a832eeda9d1b8fabc1d4d93df4f618", + "remediation.py": "39d1dae639520ed7f06e69289f0b5a5c71851f18fb38bf2649c1bd062b386e11", + "runtime.py": "f025274c2e5374944dd80c4ae37d3e52c5ae6b3b253a8afa2e24bdab0b782121", "security/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "security/integrity.py": "e0d9cdc38fe6abf4c6c3ec046adb0e0e2c69f56f418ab94581c2b3f5e2edb7ad", "security/signers.py": "61033baaa6d54dbd662e6d5b8fc4ba4b427a8fe062b72e9e38669a30088dbb07", + "serialize.py": "01476ec45f77b845357ac9bce95d9a95b055038a5d925a8d7220fffbfa90697d", "session.py": "57adc792b04eaa10a238bb9a3666feadcbb6c9760e6ddcd57bc99dc909ccf39a", + "threat_map.py": "72baeb77df978837bf64a264589d299829c31576c7d5ff17c4154a305c2ea1f2", "tui/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "tui/app.py": "e6ebd7ae8d46817f74cec2e21f13e325db2a5d5eed6e56c39be5e4b0a96a7531", + "tui/app.py": "1c4422d79e4ca9a8684a69f88fbbc8df7fc82084487656cb0a940756843079b0", "tui/formatting.py": "ea0c36bff92747150910267b012dd002c4fccb15d122554d6bf4e6ddc810cd8d", "tui/screens/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tui/screens/_pick.py": "53734b6f6138c8f5a526e0ecf2641031bee3e007698de04644246c28adc0089e", "tui/screens/categories.py": "b9f6637b5a060abd7bb22ceb69d6408a8450bd122e884b6d0e1bb206cd039772", "tui/screens/confirm.py": "c501887b22a0e4741e7bd18060d2ecbee14b25adfe0455f6f7b454923b1da89f", - "tui/screens/findings.py": "049d64508df788942436fe7c80e983e17e754fd24f79ab6265564da637d46d71", + "tui/screens/findings.py": "abd7f7ecc3ae5e5e981d188a25548bcb949db791e0a8920353f3e72daa20c2f4", "tui/screens/fix_progress.py": "0ce15a3c62edb0ae1b8a7efaf828b6b03da1e3a26ecf6c840ead9aa1055335f8", - "tui/screens/fix_result.py": "c5ac61c0bca228a7ef56e30db07aa93a264ebfe74eaff747159a7d9887335d01", + "tui/screens/fix_result.py": "ad9733aa839d7a6cf4c2c39bc84a05897a4b1c82f150e681ee22e15a258f5578", "tui/screens/guide_placeholder.py": "0a35720a47609c8a6ca06a85b7dadc2e1482ac254905515eea9cdf3ab1195620", "tui/screens/loading.py": "ecc65b9f2a1f10278f8735eb86e326d154859f53bd15a1293c1b1df61dd000fc", "tui/screens/modules.py": "2ef0de9775970c16f04ae2f3c4f1a73034750f26d045c469338a8bfe40212919", + "tui/screens/walkthrough.py": "80937abe4f21d811a883c88e4302b3ff6000dc268853690d07a755ce14c7bb51", "update/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "update/config.py": "209950d4b9342b8b19bb9cb2afe1439c31c35d3215dc79a42407eb17f8badcf0", "update/engine.py": "c11c7023653adfd1e9d2d399243eb9872ffafe258dc954478da1965917bddef3", diff --git a/tests/test_cli_scan_json.py b/tests/test_cli_scan_json.py index 6e9867a..a27a0e3 100644 --- a/tests/test_cli_scan_json.py +++ b/tests/test_cli_scan_json.py @@ -4,6 +4,11 @@ from rescue.cli import main from rescue.models import CheckResult, Finding, Severity +# click >= 8.2 removed CliRunner(mix_stderr=...): stdout and stderr are always +# captured separately now, and result.stdout is stdout alone. That is exactly what +# these tests need -- 'rescue scan --json' must put one pure JSON document on +# stdout with diagnostics kept off it -- so the runner is constructed bare. + def test_scan_json_is_single_valid_json_document_on_stdout(): # Mock the Orchestrator to return a small set of results quickly @@ -14,7 +19,7 @@ def test_scan_json_is_single_valid_json_document_on_stdout(): instance = MockOrch.return_value instance.run_checks.return_value = [(mock_mod, mock_result)] - result = CliRunner(mix_stderr=False).invoke(main, ["scan", "--json"]) + result = CliRunner().invoke(main, ["scan", "--json"]) assert result.exit_code == 0, result.output doc = json.loads(result.stdout) # must parse: stdout is pure JSON @@ -40,7 +45,7 @@ def test_scan_json_handles_bytes_and_exotic_values_in_finding_data(): with patch('rescue.cli.Orchestrator') as MockOrch: MockOrch.return_value.run_checks.return_value = [(mock_mod, mock_result)] - result = CliRunner(mix_stderr=False).invoke(main, ["scan", "--json"]) + result = CliRunner().invoke(main, ["scan", "--json"]) assert result.exit_code == 0, result.output doc = json.loads(result.stdout) # must not raise @@ -65,7 +70,7 @@ def test_scan_json_enums_are_strings(): instance = MockOrch.return_value instance.run_checks.return_value = [(mock_mod, mock_result)] - result = CliRunner(mix_stderr=False).invoke(main, ["scan", "--json"]) + result = CliRunner().invoke(main, ["scan", "--json"]) doc = json.loads(result.stdout) for mod in doc["modules"]: diff --git a/tests/test_cryptojacking_iocs.py b/tests/test_cryptojacking_iocs.py new file mode 100644 index 0000000..5d02c94 --- /dev/null +++ b/tests/test_cryptojacking_iocs.py @@ -0,0 +1,67 @@ +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.runtime import load_content_module + +IOC_DIR = ( + Path(__file__).parent.parent / "modules" / "security" / "cryptojacking_iocs" +) +LOADER = load_content_module( + "modules/security/cryptojacking_iocs/loader.py", "test_cryptojacking_iocs_loader" +) + + +def test_loader_is_importable_by_path(): + assert LOADER is not None + + +def test_database_loads_every_section(): + db = LOADER.load_cryptojacking_iocs(IOC_DIR) + assert db.version != "unknown" + assert db.miners + assert db.pools + assert db.pool_ports + assert db.browser_script_domains + assert db.browser_script_markers + assert db.browser_extensions + + +def test_platform_filtering(): + db = LOADER.load_cryptojacking_iocs(IOC_DIR) + linux_only = {m.pattern for m in db.miners_for("linux")} + windows_only = {m.pattern for m in db.miners_for("win32")} + + assert "kdevtmpfsi" in linux_only + assert "kdevtmpfsi" not in windows_only + assert "xmrig" in linux_only and "xmrig" in windows_only + + +def test_missing_directory_degrades_to_an_empty_database(): + db = LOADER.load_cryptojacking_iocs(Path("/does/not/exist")) + assert db.version == "unknown" + assert db.miners == [] + assert db.pools == [] + + +def test_data_files_are_well_formed(): + for name in ("known_miners.json", "known_pools.json", "browser_miners.json"): + with open(IOC_DIR / name) as f: + data = json.load(f) + assert data["version"] + + with open(IOC_DIR / "known_miners.json") as f: + for entry in json.load(f)["entries"]: + assert entry["pattern"] and entry["description"] + assert entry["severity"] in {"critical", "warning"} + assert set(entry["platforms"]) <= {"darwin", "linux", "win32"} + + +def test_pool_ports_are_plausible_stratum_ports(): + db = LOADER.load_cryptojacking_iocs(IOC_DIR) + assert 3333 in db.pool_ports + assert all(0 < port < 65536 for port in db.pool_ports) + # Ports shared with ordinary services would make every check noisy. + assert not {80, 443, 22, 53} & set(db.pool_ports) diff --git a/tests/test_home_network_intrusion_profile.py b/tests/test_home_network_intrusion_profile.py new file mode 100644 index 0000000..6742476 --- /dev/null +++ b/tests/test_home_network_intrusion_profile.py @@ -0,0 +1,103 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.guides import discover_guides +from rescue.profiles import ( + discover_profiles, + filter_modules_by_profile, + load_profile, + validate_profile_modules, +) +from rescue.registry import discover_modules + +PROJECT_ROOT = Path(__file__).parent.parent +PROFILE_PATH = PROJECT_ROOT / "profiles" / "home_network_intrusion.yaml" +GUIDES_DIR = PROJECT_ROOT / "guides" +MODULES_DIR = PROJECT_ROOT / "modules" + + +def test_profile_loads(): + profile = load_profile(PROFILE_PATH) + assert profile.name == "home_network_intrusion" + assert profile.display_name == "Home Network Intrusion & Cryptojacking Response" + assert profile.guides == ["home_network_intrusion"] + + +def test_profile_is_discovered_alongside_the_others(): + profiles = discover_profiles(PROJECT_ROOT / "profiles") + assert "home_network_intrusion" in profiles + + +def test_every_referenced_module_exists(): + profile = load_profile(PROFILE_PATH) + validate_profile_modules(profile, discover_modules(MODULES_DIR)) + + +def test_profile_covers_network_cryptojacking_and_monitoring(): + profile = load_profile(PROFILE_PATH) + matched = {m.name for m in filter_modules_by_profile(discover_modules(MODULES_DIR), profile)} + + # The network has to be reclaimable before the devices are cleaned. + assert {"lan_device_inventory", "arp_spoof_check", "router_security_audit"} <= matched + # Cryptojacking, including what restarts a miner after it is killed. + assert { + "crypto_miner_detect", + "win_crypto_miner_detect", + "crypto_miner_persistence", + "browser_cryptojacking_check", + } <= matched + # Who else has access to the machine. + assert {"stalkerware_scan", "remote_login_check"} <= matched + + +def test_guide_has_all_six_phases_in_order(): + guides = discover_guides(GUIDES_DIR, "home_network_intrusion") + assert [g.phase for g in guides] == [0, 1, 2, 3, 4, 5] + + +def test_guide_ends_with_reachable_help(): + guides = discover_guides(GUIDES_DIR, "home_network_intrusion") + resources = guides[-1] + assert "Resources" in resources.title + + body = "\n".join(step.body for step in resources.steps).lower() + # Abuse support comes first, because it changes the order of everything. + assert "1-800-799-7233" in body + assert "techsafety.org" in body + assert "accessnow.org/help" in body + assert "ic3.gov" in body + # And the warning that makes the rest of the list safe to use. + assert "gift card" in body + + +def test_guide_opens_with_the_safety_check_not_a_scan(): + guides = discover_guides(GUIDES_DIR, "home_network_intrusion") + phase_0 = next(g for g in guides if g.phase == 0) + assert phase_0.automatable_steps == [] + assert "safety" in phase_0.steps[0].title.lower() + + +def test_every_step_is_classified_as_automatable_or_human_only(): + for guide in discover_guides(GUIDES_DIR, "home_network_intrusion"): + classified = set(guide.automatable_steps) | set(guide.human_only_steps) + assert classified == {step.number for step in guide.steps} + assert not set(guide.automatable_steps) & set(guide.human_only_steps) + + +def test_automatable_steps_reference_modules_the_profile_actually_includes(): + profile = load_profile(PROFILE_PATH) + available = {m.name for m in filter_modules_by_profile(discover_modules(MODULES_DIR), profile)} + + referenced = set() + for guide in discover_guides(GUIDES_DIR, "home_network_intrusion"): + for step in guide.steps: + if not step.automatable: + continue + for module_name in available: + if module_name in step.body: + referenced.add(module_name) + + assert referenced, "automatable steps should name the modules they run" + assert referenced <= available diff --git a/tests/test_identity_theft_recovery_profile.py b/tests/test_identity_theft_recovery_profile.py new file mode 100644 index 0000000..ccdcb6e --- /dev/null +++ b/tests/test_identity_theft_recovery_profile.py @@ -0,0 +1,144 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.guides import discover_guides +from rescue.profiles import ( + discover_profiles, + filter_modules_by_profile, + load_profile, + validate_profile_modules, +) +from rescue.registry import discover_modules + +PROJECT_ROOT = Path(__file__).parent.parent +PROFILE_PATH = PROJECT_ROOT / "profiles" / "identity_theft_recovery.yaml" +GUIDES_DIR = PROJECT_ROOT / "guides" +MODULES_DIR = PROJECT_ROOT / "modules" + + +def _guides(): + return discover_guides(GUIDES_DIR, "identity_theft_recovery") + + +def test_profile_loads_and_is_discovered(): + profiles = discover_profiles(PROJECT_ROOT / "profiles") + assert "identity_theft_recovery" in profiles + + profile = load_profile(PROFILE_PATH) + assert profile.display_name == "Identity Theft Recovery" + assert profile.guides == ["identity_theft_recovery"] + + +def test_every_referenced_module_exists(): + profile = load_profile(PROFILE_PATH) + validate_profile_modules(profile, discover_modules(MODULES_DIR)) + + +def test_profile_answers_whether_the_device_is_leaking_credentials(): + profile = load_profile(PROFILE_PATH) + matched = { + m.name for m in filter_modules_by_profile(discover_modules(MODULES_DIR), profile) + } + assert {"keylogger_indicators", "stalkerware_scan"} <= matched + assert {"browser_extension_audit", "certificate_trust_audit"} <= matched + assert {"remote_login_check", "win_remote_access_audit"} <= matched + + +def test_guide_has_seven_phases_in_order(): + assert [g.phase for g in _guides()] == [0, 1, 2, 3, 4, 5, 6] + + +def test_resources_phase_lists_free_case_managed_help(): + resources = _guides()[-1] + assert "Resources" in resources.title + + body = "\n".join(step.body for step in resources.steps).lower() + for expected in ( + "idtheftcenter.org", # free advisors who stay with the case + "1-888-400-5530", + "1-877-908-3360", # AARP helpline, open to any age + "1-833-372-8311", # DOJ elder fraud hotline + "idcare.org", # AU/NZ equivalent + "accessnow.org/help", # for people at elevated risk + "lawhelp.org", # legal aid + ): + assert expected in body, f"resources should include {expected}" + + +def test_resources_phase_warns_about_fake_helplines_first(): + """The scam-helpline warning is useless anywhere but the top of the list.""" + resources = _guides()[-1] + first = resources.steps[0] + assert "helpline" in first.title.lower() + assert "gift card" in first.body.lower() + + +def test_recovery_runs_in_the_order_that_makes_it_work(): + titles = {g.phase: g.title for g in _guides()} + # Freezing comes before reporting, reporting before disputing: a dispute + # without an identity theft report gets a form-letter reply. + assert "Freeze" in titles[2] + assert "Report" in titles[3] + assert "Dispute" in titles[4] + + +def test_only_the_device_phase_is_automatable(): + for guide in _guides(): + if guide.phase == 1: + assert guide.automatable_steps + else: + assert guide.automatable_steps == [], ( + f"phase {guide.phase} claims automation the toolkit cannot deliver" + ) + + +def test_every_step_is_classified_exactly_once(): + for guide in _guides(): + classified = set(guide.automatable_steps) | set(guide.human_only_steps) + assert classified == {step.number for step in guide.steps} + assert not set(guide.automatable_steps) & set(guide.human_only_steps) + + +def test_automatable_steps_name_modules_the_profile_includes(): + profile = load_profile(PROFILE_PATH) + available = { + m.name for m in filter_modules_by_profile(discover_modules(MODULES_DIR), profile) + } + + referenced = set() + for guide in _guides(): + for step in guide.steps: + if step.automatable: + referenced |= {name for name in available if name in step.body} + + assert referenced, "automatable steps should name the modules they run" + assert referenced <= available + + +def test_step_titles_stand_alone_as_a_checklist(): + """The CLI prints titles only, so a title has to be actionable by itself.""" + for guide in _guides(): + for step in guide.steps: + assert len(step.title) >= 15 + assert step.body.strip(), f"phase {guide.phase} step {step.number} has no detail" + + +def test_guide_covers_the_reporting_channels_people_miss(): + body = "\n".join(step.body for guide in _guides() for step in guide.steps).lower() + for expected in ( + "identitytheft.gov", # the FTC report that unlocks the legal rights + "police report", + "annualcreditreport.com", # the free source, not a paid lookalike + "identity protection pin", # tax refund fraud + "oig.ssa.gov", # SSN misuse + "optoutprescreen.com", # prescreened offers feed the fraud + ): + assert expected in body, f"guide should mention {expected}" + + +def test_guide_points_outside_the_us(): + body = "\n".join(step.body for guide in _guides() for step in guide.steps).lower() + for expected in ("action fraud", "cifas", "anti-fraud centre", "idcare"): + assert expected in body diff --git a/tests/test_lan_common_neighbors.py b/tests/test_lan_common_neighbors.py new file mode 100644 index 0000000..7b87e46 --- /dev/null +++ b/tests/test_lan_common_neighbors.py @@ -0,0 +1,136 @@ +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.runtime import load_content_module + +NEIGHBORS = load_content_module( + "modules/network/lan_common/neighbors.py", "test_lan_common_neighbors" +) + +DARWIN_ARP = """\ +? (192.168.1.1) at 0:1c:42:0:0:8 on en0 ifscope [ethernet] +? (192.168.1.42) at a4:83:e7:1b:2c:3d on en0 ifscope [ethernet] +? (192.168.1.255) at ff:ff:ff:ff:ff:ff on en0 ifscope [ethernet] +? (224.0.0.251) at 1:0:5e:0:0:fb on en0 ifscope permanent [ethernet] +""" + +LINUX_NEIGH = """\ +192.168.1.1 dev wlan0 lladdr 00:1c:42:00:00:08 REACHABLE +192.168.1.42 dev wlan0 lladdr a4:83:e7:1b:2c:3d STALE +192.168.1.77 dev wlan0 FAILED +""" + +WINDOWS_ARP = """\ + +Interface: 192.168.1.5 --- 0x5 + Internet Address Physical Address Type + 192.168.1.1 00-1C-42-00-00-08 dynamic + 192.168.1.42 A4-83-E7-1B-2C-3D dynamic + 192.168.1.255 ff-ff-ff-ff-ff-ff static +""" + + +def test_normalise_mac_pads_and_lowercases(): + assert NEIGHBORS.normalise_mac("0:1C:42:0:0:8") == "00:1c:42:00:00:08" + assert NEIGHBORS.normalise_mac("A4-83-E7-1B-2C-3D") == "a4:83:e7:1b:2c:3d" + + +def test_normalise_mac_leaves_unparseable_values_alone(): + assert NEIGHBORS.normalise_mac("not-a-mac") == "not-a-mac" + + +def test_darwin_arp_parsing_skips_broadcast_and_multicast(): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=DARWIN_ARP, returncode=0) + neighbors = NEIGHBORS.read_neighbors("darwin") + + assert [n.ip for n in neighbors] == ["192.168.1.1", "192.168.1.42"] + assert neighbors[0].mac == "00:1c:42:00:00:08" + assert neighbors[0].interface == "en0" + + +def test_linux_neigh_parsing_skips_entries_without_a_hardware_address(): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=LINUX_NEIGH, returncode=0) + neighbors = NEIGHBORS.read_neighbors("linux") + + assert [n.ip for n in neighbors] == ["192.168.1.1", "192.168.1.42"] + assert neighbors[1].interface == "wlan0" + + +def test_windows_arp_parsing_records_the_interface_address(): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=WINDOWS_ARP, returncode=0) + neighbors = NEIGHBORS.read_neighbors("win32") + + assert [n.ip for n in neighbors] == ["192.168.1.1", "192.168.1.42"] + assert all(n.interface == "192.168.1.5" for n in neighbors) + + +def test_platform_without_support_returns_nothing(): + assert NEIGHBORS.read_neighbors("plan9") == [] + + +def test_locally_administered_bit_detection(): + vendor_assigned = NEIGHBORS.Neighbor( + ip="192.168.1.1", mac="00:1c:42:00:00:08", interface="en0" + ) + software_generated = NEIGHBORS.Neighbor( + ip="192.168.1.1", mac="02:1c:42:00:00:08", interface="en0" + ) + assert not vendor_assigned.is_locally_administered + assert software_generated.is_locally_administered + + +def test_linux_gateway_parsing(): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + stdout="default via 192.168.1.1 dev wlan0 proto dhcp metric 600\n", + returncode=0, + ) + gateways = NEIGHBORS.read_gateways("linux") + + assert len(gateways) == 1 + assert gateways[0].ip == "192.168.1.1" + assert gateways[0].interface == "wlan0" + + +def test_darwin_gateway_parsing(): + output = " route to: default\n gateway: 192.168.1.1\ninterface: en0\n" + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=output, returncode=0) + gateways = NEIGHBORS.read_gateways("darwin") + + assert gateways[0].ip == "192.168.1.1" + assert gateways[0].interface == "en0" + + +def test_windows_gateway_parsing_deduplicates(): + output = ( + "IPv4 Route Table\n" + "Network Destination Netmask Gateway Interface Metric\n" + " 0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.5 25\n" + " 0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.5 25\n" + " 192.168.1.0 255.255.255.0 On-link 192.168.1.5 281\n" + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=output, returncode=0) + gateways = NEIGHBORS.read_gateways("win32") + + assert [g.ip for g in gateways] == ["192.168.1.1"] + + +def test_command_failure_degrades_to_empty(): + with patch("subprocess.run", side_effect=OSError("no such command")): + assert NEIGHBORS.read_neighbors("linux") == [] + assert NEIGHBORS.read_gateways("linux") == [] + + +def test_vendor_lookup_uses_the_bundled_oui_table(): + vendors = NEIGHBORS.load_oui_vendors() + assert vendors, "the bundled OUI table should not be empty" + assert NEIGHBORS.vendor_for("a4:83:e7:1b:2c:3d", vendors) == "Apple" + assert NEIGHBORS.vendor_for("de:ad:be:ef:00:01", vendors) is None diff --git a/tests/test_module_appleid_security_check.py b/tests/test_module_appleid_security_check.py index 3bffd75..74bacb1 100644 --- a/tests/test_module_appleid_security_check.py +++ b/tests/test_module_appleid_security_check.py @@ -27,11 +27,19 @@ def _get_module(): return next(m for m in modules if m.name == "appleid_security_check") -def _make_appleid_plist(signed_in=True): - """Create mock plist content for MobileMeAccounts.""" - if signed_in: - return {"Accounts": [{"AccountID": "user@icloud.com"}]} - return {"Accounts": []} +def _write_appleid_plist(tmp_path, signed_in=True): + """Write a real MobileMeAccounts.plist and return its path. + + The module opens this file directly, so patching plistlib.load alone was not + enough -- open() still had to succeed, and on a non-macOS host it raised + FileNotFoundError, which the module swallows as "not signed in". Writing a + real fixture exercises the actual plist parsing instead. + """ + accounts = [{"AccountID": "user@icloud.com"}] if signed_in else [] + path = tmp_path / "MobileMeAccounts.plist" + with open(path, "wb") as f: + plistlib.dump({"Accounts": accounts}, f) + return path def _make_run_result( @@ -103,7 +111,7 @@ def fake_run(cmd, **kwargs): return fake_run -def test_appleid_security_check_discovered(): +def test_appleid_security_check_discovered(tmp_path): mod = _get_module() assert mod.name == "appleid_security_check" assert mod.category == "security" @@ -111,7 +119,7 @@ def test_appleid_security_check_discovered(): assert mod.risk_level == RiskLevel.SAFE -def test_appleid_all_secure(): +def test_appleid_all_secure(tmp_path): """Test when all Apple ID security features are enabled.""" mod = _get_module() fake_run = _make_run_result( @@ -124,10 +132,9 @@ def test_appleid_all_secure(): icloud_devices=["MacBook Pro", "iPad Pro"], ) + mod.mobileme_plist_path = _write_appleid_plist(tmp_path, True) with patch("subprocess.run", side_effect=fake_run): - with patch("pathlib.Path.exists", return_value=True): - with patch("plistlib.load", return_value=_make_appleid_plist(True)): - result = mod.check(_make_profile()) + result = mod.check(_make_profile()) # Should have INFO finding with summary assert result.has_issues @@ -137,15 +144,14 @@ def test_appleid_all_secure(): assert not any(f.severity == Severity.WARNING for f in result.findings) -def test_appleid_not_signed_in(): +def test_appleid_not_signed_in(tmp_path): """Test detection when Apple ID is not signed in.""" mod = _get_module() fake_run = _make_run_result(appleid_signin=False) + mod.mobileme_plist_path = _write_appleid_plist(tmp_path, False) with patch("subprocess.run", side_effect=fake_run): - with patch("pathlib.Path.exists", return_value=True): - with patch("plistlib.load", return_value=_make_appleid_plist(False)): - result = mod.check(_make_profile()) + result = mod.check(_make_profile()) assert result.has_issues assert any(f.data.get("check") == "appleid_signin" for f in result.findings) @@ -153,7 +159,7 @@ def test_appleid_not_signed_in(): assert signin_finding[0].severity == Severity.WARNING -def test_appleid_keychain_disabled(): +def test_appleid_keychain_disabled(tmp_path): """Test detection when iCloud Keychain is disabled.""" mod = _get_module() fake_run = _make_run_result( @@ -161,10 +167,9 @@ def test_appleid_keychain_disabled(): keychain_enabled=False, ) + mod.mobileme_plist_path = _write_appleid_plist(tmp_path, True) with patch("subprocess.run", side_effect=fake_run): - with patch("pathlib.Path.exists", return_value=True): - with patch("plistlib.load", return_value=_make_appleid_plist(True)): - result = mod.check(_make_profile()) + result = mod.check(_make_profile()) assert result.has_issues assert any(f.data.get("check") == "icloud_keychain" for f in result.findings) @@ -172,7 +177,7 @@ def test_appleid_keychain_disabled(): assert keychain_finding[0].severity == Severity.WARNING -def test_appleid_autoupdate_disabled(): +def test_appleid_autoupdate_disabled(tmp_path): """Test detection when automatic updates are disabled.""" mod = _get_module() fake_run = _make_run_result( @@ -180,10 +185,9 @@ def test_appleid_autoupdate_disabled(): autoupdate_enabled=False, ) + mod.mobileme_plist_path = _write_appleid_plist(tmp_path, True) with patch("subprocess.run", side_effect=fake_run): - with patch("pathlib.Path.exists", return_value=True): - with patch("plistlib.load", return_value=_make_appleid_plist(True)): - result = mod.check(_make_profile()) + result = mod.check(_make_profile()) assert result.has_issues assert any(f.data.get("check") == "autoupdate_disabled" for f in result.findings) @@ -191,7 +195,7 @@ def test_appleid_autoupdate_disabled(): assert update_finding[0].severity == Severity.WARNING -def test_appleid_multiple_issues(): +def test_appleid_multiple_issues(tmp_path): """Test when multiple security issues are detected.""" mod = _get_module() fake_run = _make_run_result( @@ -200,10 +204,9 @@ def test_appleid_multiple_issues(): autoupdate_enabled=False, ) + mod.mobileme_plist_path = _write_appleid_plist(tmp_path, False) with patch("subprocess.run", side_effect=fake_run): - with patch("pathlib.Path.exists", return_value=True): - with patch("plistlib.load", return_value=_make_appleid_plist(False)): - result = mod.check(_make_profile()) + result = mod.check(_make_profile()) assert result.has_issues checks = [f.data.get("check") for f in result.findings] @@ -214,89 +217,85 @@ def test_appleid_multiple_issues(): assert len(result.findings) >= 3 -def test_appleid_fix_signin(): +def test_appleid_fix_signin(tmp_path): """Test fix recommendation for Apple ID signin.""" mod = _get_module() fake_run = _make_run_result(appleid_signin=False) + mod.mobileme_plist_path = _write_appleid_plist(tmp_path, False) with patch("subprocess.run", side_effect=fake_run): - with patch("pathlib.Path.exists", return_value=True): - with patch("plistlib.load", return_value=_make_appleid_plist(False)): - check = mod.check(_make_profile()) - fix = mod.fix(check, Mode.MANUAL) + check = mod.check(_make_profile()) + fix = mod.fix(check, Mode.MANUAL) assert len(fix.actions) > 0 assert any("sign in" in a.title.lower() for a in fix.actions) -def test_appleid_fix_keychain(): +def test_appleid_fix_keychain(tmp_path): """Test fix recommendation for iCloud Keychain.""" mod = _get_module() fake_run = _make_run_result(keychain_enabled=False) + mod.mobileme_plist_path = _write_appleid_plist(tmp_path, True) with patch("subprocess.run", side_effect=fake_run): - with patch("pathlib.Path.exists", return_value=True): - with patch("plistlib.load", return_value=_make_appleid_plist(True)): - check = mod.check(_make_profile()) - fix = mod.fix(check, Mode.MANUAL) + check = mod.check(_make_profile()) + fix = mod.fix(check, Mode.MANUAL) assert len(fix.actions) > 0 assert any("keychain" in a.title.lower() for a in fix.actions) -def test_appleid_fix_autoupdate(): +def test_appleid_fix_autoupdate(tmp_path): """Test fix recommendation for automatic updates.""" mod = _get_module() fake_run = _make_run_result(autoupdate_enabled=False) + mod.mobileme_plist_path = _write_appleid_plist(tmp_path, True) with patch("subprocess.run", side_effect=fake_run): - with patch("pathlib.Path.exists", return_value=True): - with patch("plistlib.load", return_value=_make_appleid_plist(True)): - check = mod.check(_make_profile()) - fix = mod.fix(check, Mode.MANUAL) + check = mod.check(_make_profile()) + fix = mod.fix(check, Mode.MANUAL) assert len(fix.actions) > 0 assert any("update" in a.title.lower() for a in fix.actions) -def test_appleid_handles_missing_plist(): +def test_appleid_handles_missing_plist(tmp_path): """Test graceful handling when MobileMeAccounts.plist is missing.""" mod = _get_module() fake_run = _make_run_result(appleid_signin=False) + mod.mobileme_plist_path = tmp_path / "absent.plist" with patch("subprocess.run", side_effect=fake_run): - with patch("pathlib.Path.exists", return_value=False): - result = mod.check(_make_profile()) + result = mod.check(_make_profile()) # Should still complete and flag no signin assert result.has_issues assert any(f.data.get("check") == "appleid_signin" for f in result.findings) -def test_appleid_handles_subprocess_error(): +def test_appleid_handles_subprocess_error(tmp_path): """Test graceful handling of subprocess errors.""" mod = _get_module() def error_run(cmd, **kwargs): raise OSError("Command failed") + mod.mobileme_plist_path = tmp_path / "absent.plist" with patch("subprocess.run", side_effect=error_run): - with patch("pathlib.Path.exists", return_value=False): - result = mod.check(_make_profile()) + result = mod.check(_make_profile()) # Should still complete without crashing assert isinstance(result.findings, list) -def test_appleid_summary_info_always_present(): +def test_appleid_summary_info_always_present(tmp_path): """Test that summary info finding is always present.""" mod = _get_module() fake_run = _make_run_result() + mod.mobileme_plist_path = _write_appleid_plist(tmp_path, True) with patch("subprocess.run", side_effect=fake_run): - with patch("pathlib.Path.exists", return_value=True): - with patch("plistlib.load", return_value=_make_appleid_plist(True)): - result = mod.check(_make_profile()) + result = mod.check(_make_profile()) # Summary should always be present assert any(f.data.get("check") == "appleid_summary" for f in result.findings) 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_code_signature_audit.py b/tests/test_module_code_signature_audit.py new file mode 100644 index 0000000..23aeb1c --- /dev/null +++ b/tests/test_module_code_signature_audit.py @@ -0,0 +1,295 @@ +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import ( + ActionKind, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.registry import discover_modules + +MODULE_NAME = "code_signature_audit" + + +def _module_object(mod): + return sys.modules[type(mod).__module__] + + +def _make_profile(platform=Platform.DARWIN): + return SystemProfile( + platform=platform, + os_name="macOS" if platform == Platform.DARWIN else "Windows 11", + 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" + return next(m for m in discover_modules(modules_dir) if m.name == MODULE_NAME) + + +class _Result: + """Stand-in for rescue.command.CommandResult.""" + + def __init__(self, ok=True, stdout="", stderr="", timed_out=False, error=None): + self.ok = ok + self.stdout = stdout + self.stderr = stderr + self.timed_out = timed_out + self.error = error + + +@pytest.fixture +def mod(tmp_path): + m = _get_module() + apps = tmp_path / "Applications" + apps.mkdir() + m.app_dirs = [str(apps)] + m.max_binaries = 60 + m._apps_dir = apps + m._tmp = tmp_path + return m + + +def _install(mod, name): + (mod._apps_dir / f"{name}.app").mkdir() + + +def _finding(result, check): + return next((f for f in result.findings if f.data.get("check") == check), None) + + +def _findings(result, check): + return [f for f in result.findings if f.data.get("check") == check] + + +def _run_with(mod, handler, platform=Platform.DARWIN): + with patch.object(_module_object(mod), "run", side_effect=handler): + return mod.check(_make_profile(platform)) + + +def test_discovered_with_expected_metadata(): + m = _get_module() + assert m.name == MODULE_NAME + assert m.category == "security" + assert m.risk_level == RiskLevel.SAFE + assert getattr(m, "auto_apply", False) is False + for code in ( + "security.code_signature_audit.tampered_binary", + "security.code_signature_audit.unsigned_system_app", + "security.code_signature_audit.gatekeeper_rejected", + "security.code_signature_audit.inventory", + ): + assert code in m.emits_codes + + +def test_all_valid_signatures_produce_no_warnings(mod): + _install(mod, "Safari") + _install(mod, "Mail") + + result = _run_with(mod, lambda args, **kw: _Result(ok=True)) + + assert not any(f.severity == Severity.CRITICAL for f in result.findings) + assert not any(f.severity == Severity.WARNING for f in result.findings) + inventory = _finding(result, "inventory") + assert inventory.data["examined"] == 2 + assert inventory.data["tampered"] == 0 + + +def test_broken_signature_is_critical(mod): + _install(mod, "Tampered") + + def handler(args, **kw): + if args[0] == "codesign": + return _Result(ok=False, stderr="a sealed resource is missing or invalid") + return _Result(ok=True) + + result = _run_with(mod, handler) + + finding = _finding(result, "tampered_binary") + assert finding is not None + assert finding.severity == Severity.CRITICAL + assert "Tampered" in finding.title + + +def test_unsigned_app_in_system_location_is_a_warning(mod): + """An unsigned app is WARNING, never CRITICAL: it has benign explanations.""" + mod.app_dirs = ["/Applications"] + + def handler(args, **kw): + if args[0] == "codesign": + return _Result(ok=False, stderr="code object is not signed at all") + return _Result(ok=True) + + with patch.object(type(mod), "_collect_darwin_apps", lambda self: [Path("/Applications/Sketchy.app")]): + result = _run_with(mod, handler) + + finding = _finding(result, "unsigned_system_app") + assert finding is not None + assert finding.severity == Severity.WARNING + # It must not overclaim: the text has to leave room for legitimate cases. + assert "evidence, not a verdict" in finding.description + + +def test_unsigned_app_in_user_location_is_not_flagged(mod): + """~/Applications is the user's own business; flagging it would be noise.""" + _install(mod, "MyOwnBuild") + + def handler(args, **kw): + if args[0] == "codesign": + return _Result(ok=False, stderr="code object is not signed at all") + return _Result(ok=True) + + result = _run_with(mod, handler) + + assert _finding(result, "unsigned_system_app") is None + assert _finding(result, "tampered_binary") is None + + +def test_gatekeeper_rejection_is_a_warning(mod): + _install(mod, "NotNotarised") + + def handler(args, **kw): + if args[0] == "codesign": + return _Result(ok=True) + return _Result(ok=False, stderr="rejected (the code is valid but does not seem to be an app)") + + result = _run_with(mod, handler) + + finding = _finding(result, "gatekeeper_rejected") + assert finding is not None + assert finding.severity == Severity.WARNING + + +def test_timed_out_check_is_not_reported_as_tampering(mod): + """A check that did not complete must never be presented as a failure.""" + _install(mod, "Slow") + + def handler(args, **kw): + if args[0] == "codesign": + return _Result(ok=False, timed_out=True) + return _Result(ok=True) + + result = _run_with(mod, handler) + + assert _finding(result, "tampered_binary") is None + assert _finding(result, "unsigned_system_app") is None + + +def test_scan_is_capped_and_says_so(mod): + for i in range(10): + _install(mod, f"App{i}") + mod.max_binaries = 4 + + result = _run_with(mod, lambda args, **kw: _Result(ok=True)) + + inventory = _finding(result, "inventory") + assert inventory.data["examined"] == 4 + assert inventory.data["total_present"] == 10 + assert inventory.data["coverage_capped"] is True + assert "COVERAGE LIMIT" in inventory.description + + +def test_uncapped_scan_states_full_coverage(mod): + _install(mod, "OnlyOne") + result = _run_with(mod, lambda args, **kw: _Result(ok=True)) + inventory = _finding(result, "inventory") + assert inventory.data["coverage_capped"] is False + assert "All applications in the scanned locations were checked" in inventory.description + + +def test_missing_application_directory_is_tolerated(mod): + mod.app_dirs = [str(mod._tmp / "absent")] + result = _run_with(mod, lambda args, **kw: _Result(ok=True)) + inventory = _finding(result, "inventory") + assert inventory.data["examined"] == 0 + + +def test_windows_hashmismatch_is_critical(mod): + def handler(args, **kw): + return _Result( + ok=True, + stdout=( + "C:\\Program Files\\Good\\good.exe|Valid|CN=Vendor\n" + "C:\\Program Files\\Bad\\bad.exe|HashMismatch|CN=Vendor\n" + "C:\\Program Files\\Plain\\plain.exe|NotSigned|\n" + ), + ) + + mod.app_dirs = [r"C:\Program Files"] + result = _run_with(mod, handler, Platform.WIN32) + + tampered = _finding(result, "tampered_binary") + assert tampered is not None + assert tampered.severity == Severity.CRITICAL + assert _finding(result, "unsigned_system_app") is not None + + +def test_windows_powershell_failure_is_tolerated(mod): + mod.app_dirs = [r"C:\Program Files"] + result = _run_with(mod, lambda args, **kw: _Result(ok=False), Platform.WIN32) + inventory = _finding(result, "inventory") + assert inventory.data["examined"] == 0 + + +def test_unsupported_platform_says_so(mod): + result = mod.check(_make_profile(Platform.LINUX)) + assert result.supported is False + assert result.unsupported_reason + assert result.findings == [] + + +def test_fix_is_guidance_only(mod): + _install(mod, "Tampered") + + def handler(args, **kw): + if args[0] == "codesign": + return _Result(ok=False, stderr="a sealed resource is missing or invalid") + return _Result(ok=True) + + check = _run_with(mod, handler) + fix = mod.fix(check, Mode.AUTO) + + assert fix.actions + for action in fix.actions: + assert action.kind == ActionKind.GUIDANCE + assert action.executed is False + + +def test_fix_for_tampering_says_do_not_run_it(mod): + _install(mod, "Tampered") + + def handler(args, **kw): + if args[0] == "codesign": + return _Result(ok=False, stderr="a sealed resource is missing or invalid") + return _Result(ok=True) + + check = _run_with(mod, handler) + fix = mod.fix(check, Mode.AUTO) + + action = next(a for a in fix.actions if a.data.get("check") == "tampered_binary") + assert "do not run it again" in action.description.lower() + + +def test_capped_scan_warns_against_reading_it_as_all_clear(mod): + for i in range(6): + _install(mod, f"App{i}") + mod.max_binaries = 2 + + check = _run_with(mod, lambda args, **kw: _Result(ok=True)) + fix = mod.fix(check, Mode.AUTO) + + action = next(a for a in fix.actions if a.data.get("check") == "coverage_limit") + assert "everything is signed" in action.description 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_disk_permissions_repair.py b/tests/test_module_disk_permissions_repair.py index fc2b50e..6e1a6e4 100644 --- a/tests/test_module_disk_permissions_repair.py +++ b/tests/test_module_disk_permissions_repair.py @@ -1,3 +1,4 @@ +import os import sys from pathlib import Path from unittest.mock import patch, MagicMock @@ -26,7 +27,17 @@ def _get_module(): return next(m for m in modules if m.name == "disk_permissions_repair") -def _make_stat_result(st_uid=501, st_gid=20, st_mode=0o40755): +# The module checks ownership against os.getuid(), so the "correctly owned" +# fixture uid must be this process's uid -- not a hardcoded 501, which only +# matched on a typical macOS user account and failed as root or on CI. +CURRENT_UID = os.getuid() + +# A healthy /usr/local is specifically *not* root-owned, so it cannot reuse +# CURRENT_UID when the suite runs as root (as it does in CI containers). +NON_ROOT_UID = CURRENT_UID if CURRENT_UID != 0 else 501 + + +def _make_stat_result(st_uid=CURRENT_UID, st_gid=20, st_mode=0o40755): """Create a mock stat result.""" result = MagicMock() result.st_uid = st_uid @@ -38,7 +49,7 @@ def _make_stat_result(st_uid=501, st_gid=20, st_mode=0o40755): def _mock_owner_method(uid): """Return a mock owner method that returns the expected user.""" def owner_method(): - if uid == 501: + if uid == CURRENT_UID: return "testuser" elif uid == 0: return "root" @@ -120,12 +131,14 @@ def test_disk_permissions_repair_healthy(): def mock_stat(path_self): # Return healthy stat result based on path path_str = str(path_self) + if "/usr/local" in path_str: + # Healthy /usr/local is owned by a normal user, not root. + return _make_stat_result(st_uid=NON_ROOT_UID, st_mode=0o40755) if "/tmp" in path_str or "/var/tmp" in path_str: # /tmp and /var/tmp need sticky bit + 777 permissions - return _make_stat_result(st_uid=501, st_mode=0o41777) # 0o40000 (dir) + 0o01000 (sticky) + 0o777 (perms) - else: - # Other directories: normal user directory - return _make_stat_result(st_uid=501, st_mode=0o40755) + return _make_stat_result(st_uid=CURRENT_UID, st_mode=0o41777) # 0o40000 (dir) + 0o01000 (sticky) + 0o777 (perms) + # Home directories must match the running uid. + return _make_stat_result(st_uid=CURRENT_UID, st_mode=0o40755) with patch("subprocess.run", side_effect=_fake_run_healthy()): with patch("pathlib.Path.exists", return_value=True): @@ -142,7 +155,7 @@ def test_disk_permissions_repair_home_ownership_mismatch(): mod = _get_module() with patch("subprocess.run", side_effect=_fake_run_home_ownership_mismatch()): with patch("pathlib.Path.exists", return_value=True): - with patch("pathlib.Path.stat", return_value=_make_stat_result(st_uid=501)): + with patch("pathlib.Path.stat", return_value=_make_stat_result(st_uid=CURRENT_UID)): with patch("pathlib.Path.owner", return_value="testuser"): with patch("os.access", return_value=True): result = mod.check(_make_profile()) @@ -159,7 +172,7 @@ def test_disk_permissions_repair_tmp_permissions(): with patch("subprocess.run", side_effect=_fake_run_healthy()): with patch("pathlib.Path.exists", return_value=True): # Regular directory mode without sticky bit (0o40755 = drwxr-xr-x) - with patch("pathlib.Path.stat", return_value=_make_stat_result(st_uid=501, st_mode=0o40755)): + with patch("pathlib.Path.stat", return_value=_make_stat_result(st_uid=CURRENT_UID, st_mode=0o40755)): with patch("pathlib.Path.owner", return_value="testuser"): with patch("os.access", return_value=True): result = mod.check(_make_profile()) @@ -181,10 +194,10 @@ def mock_stat(path_self): return _make_stat_result(st_uid=0, st_mode=0o40755) elif "/tmp" in path_str or "/var/tmp" in path_str: # /tmp and /var/tmp with correct sticky bit permissions - return _make_stat_result(st_uid=501, st_mode=0o41777) + return _make_stat_result(st_uid=CURRENT_UID, st_mode=0o41777) else: # Other directories: normal user directory - return _make_stat_result(st_uid=501, st_mode=0o40755) + return _make_stat_result(st_uid=CURRENT_UID, st_mode=0o40755) with patch("subprocess.run", side_effect=_fake_run_healthy()): with patch("pathlib.Path.exists", return_value=True): @@ -201,7 +214,7 @@ def test_disk_permissions_repair_fix_is_informational(): mod = _get_module() with patch("subprocess.run", side_effect=_fake_run_home_ownership_mismatch()): with patch("pathlib.Path.exists", return_value=True): - with patch("pathlib.Path.stat", return_value=_make_stat_result(st_uid=501)): + with patch("pathlib.Path.stat", return_value=_make_stat_result(st_uid=CURRENT_UID)): with patch("pathlib.Path.owner", return_value="testuser"): with patch("os.access", return_value=True): check = mod.check(_make_profile()) diff --git a/tests/test_module_evidence_bundle.py b/tests/test_module_evidence_bundle.py new file mode 100644 index 0000000..c467131 --- /dev/null +++ b/tests/test_module_evidence_bundle.py @@ -0,0 +1,239 @@ +import json +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import ( + ActionKind, + Finding, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.registry import discover_modules + +MODULE_NAME = "evidence_bundle" + + +def _module_object(mod): + return sys.modules[type(mod).__module__] + + +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" + return next(m for m in discover_modules(modules_dir) if m.name == MODULE_NAME) + + +class _Result: + def __init__(self, ok=True, stdout="output", truncated=False): + self.ok = ok + self.stdout = stdout + self.stderr = "" + self.timed_out = False + self.error = None + self.truncated = truncated + + +@pytest.fixture +def mod(tmp_path): + m = _get_module() + m.state_dir = tmp_path / "state" + m._tmp = tmp_path + return m + + +def _finding(result, check): + return next((f for f in result.findings if f.data.get("check") == check), None) + + +def _indicator(severity=Severity.CRITICAL): + return Finding( + title="Something bad", + description="an indicator", + severity=severity, + category="security", + ) + + +def test_discovered_with_expected_metadata(): + m = _get_module() + assert m.name == MODULE_NAME + assert m.category == "security" + assert m.risk_level == RiskLevel.SAFE + assert getattr(m, "auto_apply", False) is False + # Runs early: evidence must be considered before remediation modules act. + assert m.priority >= 85 + + +def test_check_never_writes_a_bundle(mod): + """check() observes; creating files needs an explicit human decision.""" + result = mod.check(_make_profile()) + readiness = _finding(result, "readiness") + assert readiness.data["bundle_written"] is False + assert readiness.data["existing_bundles"] == [] + bundles = list((mod.state_dir / "evidence").glob("evidence-*")) + assert bundles == [] + + +def test_check_reports_collectable_categories(mod): + result = mod.check(_make_profile()) + readiness = _finding(result, "readiness") + assert "process list" in readiness.data["collectable_categories"] + assert "network connections" in readiness.data["collectable_categories"] + + +def test_check_lists_what_is_never_collected(mod): + result = mod.check(_make_profile()) + readiness = _finding(result, "readiness") + body = readiness.description.lower() + for forbidden in ("password", "token", "cookie", "private key", "browser history"): + assert forbidden in body + + +def test_unwritable_destination_is_reported(mod): + blocked = mod._tmp / "blocked" + blocked.write_text("") + mod.state_dir = blocked / "nested" + result = mod.check(_make_profile()) + finding = _finding(result, "destination_unwritable") + assert finding is not None + assert finding.severity == Severity.WARNING + + +def test_indicators_without_a_bundle_produce_a_warning(mod): + result = mod.assess_with_context(_make_profile(), [_indicator()]) + finding = _finding(result, "no_bundle_captured") + assert finding is not None + assert finding.severity == Severity.WARNING + assert finding.data["indicator_count"] == 1 + # The warning must lead the report, ahead of the readiness inventory. + assert result.findings[0].data.get("check") == "no_bundle_captured" + + +def test_no_indicators_produces_no_warning(mod): + result = mod.assess_with_context(_make_profile(), [_indicator(Severity.INFO)]) + assert _finding(result, "no_bundle_captured") is None + + +def test_existing_bundle_suppresses_the_warning(mod): + with patch.object(_module_object(mod), "run", return_value=_Result()): + mod.write_bundle(_make_profile()) + result = mod.assess_with_context(_make_profile(), [_indicator()]) + assert _finding(result, "no_bundle_captured") is None + + +def test_write_bundle_produces_a_hashed_manifest(mod): + with patch.object(_module_object(mod), "run", return_value=_Result(stdout="PID CMD\n1 init\n")): + bundle = mod.write_bundle(_make_profile()) + + assert bundle is not None + manifest = json.loads((bundle / "manifest.json").read_text()) + assert manifest["manifest_version"] == 1 + assert manifest["items"] + for item in manifest["items"]: + assert len(item["sha256"]) == 64 + assert item["command"] + assert item["collected_at"] + assert (bundle / item["file"]).is_file() + + +def test_manifest_records_omissions_rather_than_hiding_them(mod): + def only_ps_works(args, **kw): + if args[0] == "ps": + return _Result(stdout="PID CMD\n") + return _Result(ok=False) + + with patch.object(_module_object(mod), "run", side_effect=only_ps_works): + bundle = mod.write_bundle(_make_profile()) + + manifest = json.loads((bundle / "manifest.json").read_text()) + assert manifest["omitted"], "a failed collection must be recorded, not dropped" + assert all("reason" in o for o in manifest["omitted"]) + + +def test_manifest_declares_what_is_never_collected(mod): + with patch.object(_module_object(mod), "run", return_value=_Result()): + bundle = mod.write_bundle(_make_profile()) + manifest = json.loads((bundle / "manifest.json").read_text()) + assert manifest["never_collected"] + assert manifest["chain_of_custody"] + assert "not transmitted" in manifest["chain_of_custody"].lower() + + +def test_bundle_is_written_outside_the_repository(mod): + with patch.object(_module_object(mod), "run", return_value=_Result()): + bundle = mod.write_bundle(_make_profile()) + repo_root = Path(__file__).parent.parent.resolve() + assert repo_root not in bundle.resolve().parents + + +def test_collection_commands_never_touch_credential_stores(mod): + """Structural guarantee: no collection command reads a secret store.""" + for platform in (Platform.DARWIN, Platform.WIN32, Platform.LINUX): + for _category, command in mod._collection_commands(_make_profile(platform)): + joined = " ".join(command).lower() + for forbidden in ("security", "keychain", "cmdkey", "vaultcmd", "cookies"): + assert forbidden not in joined, f"{joined} may read a credential store" + + +def test_bundle_item_size_is_bounded(mod): + huge = "x" * (2 * 1024 * 1024) + with patch.object(_module_object(mod), "run", return_value=_Result(stdout=huge)): + bundle = mod.write_bundle(_make_profile()) + + manifest = json.loads((bundle / "manifest.json").read_text()) + for item in manifest["items"]: + assert item["bytes"] <= 512 * 1024 + assert item["truncated"] is True + + +def test_write_bundle_on_unwritable_destination_returns_none(mod): + blocked = mod._tmp / "blocked" + blocked.write_text("") + mod.state_dir = blocked / "nested" + with patch.object(_module_object(mod), "run", return_value=_Result()): + assert mod.write_bundle(_make_profile()) is None + + +def test_fix_is_guidance_only(mod): + check = mod.check(_make_profile()) + fix = mod.fix(check, Mode.AUTO) + assert fix.actions + for action in fix.actions: + assert action.kind == ActionKind.GUIDANCE + assert action.executed is False + + +def test_fix_leads_with_preserve_before_repair(mod): + check = mod.check(_make_profile()) + fix = mod.fix(check, Mode.AUTO) + first = fix.actions[0] + assert first.data.get("check") == "order_of_operations" + body = first.description.lower() + assert body.index("capture evidence now") < body.index("work through remediation") + + +def test_fix_tells_work_machine_users_to_stop(mod): + check = mod.check(_make_profile()) + fix = mod.fix(check, Mode.AUTO) + combined = " ".join(a.description for a in fix.actions).lower() + assert "work machine" in combined + assert "domestic abuse advocate" in combined diff --git a/tests/test_module_kext_audit.py b/tests/test_module_kext_audit.py index 5e74c9e..3470570 100644 --- a/tests/test_module_kext_audit.py +++ b/tests/test_module_kext_audit.py @@ -49,7 +49,7 @@ def fake_run(cmd, **kwargs): " 1 0 0xffffff7f80000000 0x1000 0x1000 com.apple.driver.AppleACPIPlatform (1.0) <7 6 5 4 3 1>\n" " 2 0 0xffffff7f80001000 0x2000 0x2000 com.apple.driver.AppleNVMe (2.0) <7 6 5 4 3 1>\n" ) - elif "find" in cmd_str and "/Library/Extensions" in cmd_str: + elif "find" in cmd_str: return _make_subprocess_result("") return _make_subprocess_result() return fake_run @@ -70,7 +70,7 @@ def fake_run(cmd, **kwargs): " 2 3 0xffffff7f80001000 0x5000 0x4000 org.virtualbox.kext.VBoxDrv (7.0.6) <7 6 5 4 3 1>\n" " 3 1 0xffffff7f80006000 0x2000 0x1000 org.virtualbox.kext.VBoxNetFlt (7.0.6) <2 1>\n" ) - elif "find" in cmd_str and "/Library/Extensions" in cmd_str: + elif "find" in cmd_str: return _make_subprocess_result("") return _make_subprocess_result() return fake_run @@ -90,7 +90,7 @@ def fake_run(cmd, **kwargs): " 1 0 0xffffff7f80000000 0x1000 0x1000 com.apple.driver.AppleACPIPlatform (1.0) <7 6 5 4 3 1>\n" " 2 2 0xffffff7f80001000 0x3000 0x2000 com.vmware.kext.vmci (13.5.12) <7 6 5 4 3 1>\n" ) - elif "find" in cmd_str and "/Library/Extensions" in cmd_str: + elif "find" in cmd_str: return _make_subprocess_result("") return _make_subprocess_result() return fake_run @@ -110,7 +110,7 @@ def fake_run(cmd, **kwargs): " 1 0 0xffffff7f80000000 0x1000 0x1000 com.apple.driver.AppleACPIPlatform (1.0) <7 6 5 4 3 1>\n" " 2 1 0xffffff7f80001000 0x3000 0x2000 com.example.driver.Unsigned (1.0.0)\n" ) - elif "find" in cmd_str and "/Library/Extensions" in cmd_str: + elif "find" in cmd_str: return _make_subprocess_result("") return _make_subprocess_result() return fake_run @@ -129,7 +129,7 @@ def fake_run(cmd, **kwargs): "Index Refs Address Size Wired Name (Version) \n" " 1 0 0xffffff7f80000000 0x1000 0x1000 com.apple.driver.AppleACPIPlatform (1.0) <7 6 5 4 3 1>\n" ) - elif "find" in cmd_str and "/Library/Extensions" in cmd_str: + elif "find" in cmd_str: return _make_subprocess_result( "/Library/Extensions/OldDriver.kext\n" "/Library/Extensions/LegacyHW.kext\n" @@ -187,9 +187,12 @@ def test_kext_audit_unsigned_kext_critical(): assert any(f.severity == Severity.CRITICAL for f in result.findings) -def test_kext_audit_kext_files_detected(): +def test_kext_audit_kext_files_detected(tmp_path): """Kext files in /Library/Extensions/ should be flagged as WARNING""" mod = _get_module() + # The module checks the directory exists before shelling out to `find`, so + # without a real directory here the mocked `find` was never reached. + mod.extensions_dir = str(tmp_path) with patch("subprocess.run", side_effect=_fake_run_with_kext_files()): result = mod.check(_make_profile()) assert 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_notification_center_check.py b/tests/test_module_notification_center_check.py index caf1e12..b754c5e 100644 --- a/tests/test_module_notification_center_check.py +++ b/tests/test_module_notification_center_check.py @@ -22,10 +22,22 @@ def _make_profile(): ) -def _get_module(): +def _get_module(tmp_path=None): modules_dir = Path(__file__).parent.parent / "modules" modules = discover_modules(modules_dir) - return next(m for m in modules if m.name == "notification_center_check") + mod = next(m for m in modules if m.name == "notification_center_check") + if tmp_path is not None: + # The module reads two real paths under ~/Library and returns early if + # they are absent, which on a non-macOS host meant check() short-circuited + # before the mocked `defaults` call was ever reached. Point both at a + # fixture so these tests exercise the parser on every platform. + prefs = tmp_path / "com.apple.ncprefs.plist" + prefs.write_bytes(b"") + nc_dir = tmp_path / "NotificationCenter" + nc_dir.mkdir() + mod.ncprefs_path = prefs + mod.notification_center_dir = nc_dir + return mod def test_notification_center_check_discovered(): @@ -37,9 +49,9 @@ def test_notification_center_check_discovered(): assert mod.risk_level == RiskLevel.SAFE -def test_notification_center_check_clean(): +def test_notification_center_check_clean(tmp_path): """Test when notification center is clean (no issues).""" - mod = _get_module() + mod = _get_module(tmp_path) def mock_walk(path): return [] @@ -59,9 +71,9 @@ def mock_walk(path): assert not any(f.severity == Severity.CRITICAL for f in result.findings) -def test_notification_center_check_large_database(): +def test_notification_center_check_large_database(tmp_path): """Test detection of bloated notification database.""" - mod = _get_module() + mod = _get_module(tmp_path) def mock_walk(path): # Return fake files that add up to 600MB @@ -93,9 +105,9 @@ def mock_stat_func(): assert isinstance(result.findings, list) -def test_notification_center_check_too_many_apps(): +def test_notification_center_check_too_many_apps(tmp_path): """Test detection of too many apps with notification permissions.""" - mod = _get_module() + mod = _get_module(tmp_path) # Create defaults output with 60 apps (using correct format) defaults_output = "\n".join( @@ -119,9 +131,9 @@ def test_notification_center_check_too_many_apps(): assert "overload" in app_warnings[0].title.lower() -def test_notification_center_check_too_many_alerts(): +def test_notification_center_check_too_many_alerts(tmp_path): """Test detection of too many apps using Alerts style.""" - mod = _get_module() + mod = _get_module(tmp_path) # Create defaults output with 15 apps using alertStyle = 1 (Alerts) defaults_output = "\n".join( @@ -150,9 +162,9 @@ def test_notification_center_check_too_many_alerts(): assert "alerts" in alert_warnings[0].title.lower() -def test_notification_center_check_dnd_active(): +def test_notification_center_check_dnd_active(tmp_path): """Test detection of active Do Not Disturb.""" - mod = _get_module() + mod = _get_module(tmp_path) defaults_output = """ com.example.app1 = { @@ -178,9 +190,9 @@ def test_notification_center_check_dnd_active(): assert summary_findings[0].data.get("dnd_active") is True -def test_notification_center_check_multiple_issues(): +def test_notification_center_check_multiple_issues(tmp_path): """Test detection of multiple notification issues simultaneously.""" - mod = _get_module() + mod = _get_module(tmp_path) # Create defaults output with >50 apps and >10 using alerts defaults_output = "\n".join( @@ -214,9 +226,9 @@ def test_notification_center_check_multiple_issues(): assert "alert_style_count" in checks -def test_notification_center_check_fix_recommendations_exist(): +def test_notification_center_check_fix_recommendations_exist(tmp_path): """Test that fix provides recommendations for findings.""" - mod = _get_module() + mod = _get_module(tmp_path) defaults_output = "\n".join([f"com.example.app{i} = {{" for i in range(60)]) @@ -237,9 +249,9 @@ def test_notification_center_check_fix_recommendations_exist(): assert all(a.success is True for a in fix.actions) -def test_notification_center_check_fix_app_permissions(): +def test_notification_center_check_fix_app_permissions(tmp_path): """Test fix recommendations for too many app permissions.""" - mod = _get_module() + mod = _get_module(tmp_path) defaults_output = "\n".join([f"com.example.app{i} = {{" for i in range(60)]) @@ -261,9 +273,9 @@ def test_notification_center_check_fix_app_permissions(): assert all(a.risk_level == RiskLevel.SAFE for a in app_actions) -def test_notification_center_check_fix_alerts(): +def test_notification_center_check_fix_alerts(tmp_path): """Test fix recommendations for too many alerts.""" - mod = _get_module() + mod = _get_module(tmp_path) defaults_output = "\n".join( [ @@ -290,9 +302,9 @@ def test_notification_center_check_fix_alerts(): assert all(a.risk_level == RiskLevel.SAFE for a in alert_actions) -def test_notification_center_check_subprocess_error(): +def test_notification_center_check_subprocess_error(tmp_path): """Test graceful handling of subprocess errors.""" - mod = _get_module() + mod = _get_module(tmp_path) def error_run(cmd, **kwargs): raise OSError("Command failed") @@ -305,9 +317,9 @@ def error_run(cmd, **kwargs): assert isinstance(result.findings, list) -def test_notification_center_check_subprocess_timeout(): +def test_notification_center_check_subprocess_timeout(tmp_path): """Test graceful handling of subprocess timeout.""" - mod = _get_module() + mod = _get_module(tmp_path) def timeout_run(cmd, **kwargs): raise Exception("Timeout") @@ -320,9 +332,9 @@ def timeout_run(cmd, **kwargs): assert isinstance(result.findings, list) -def test_notification_center_check_missing_prefs(): +def test_notification_center_check_missing_prefs(tmp_path): """Test handling when preferences file doesn't exist.""" - mod = _get_module() + mod = _get_module(tmp_path) with patch("modules.performance.notification_center_check.os.walk", return_value=[]): with patch("modules.performance.notification_center_check.Path.exists", return_value=False): @@ -332,9 +344,9 @@ def test_notification_center_check_missing_prefs(): assert isinstance(result.findings, list) -def test_notification_center_check_empty_database(): +def test_notification_center_check_empty_database(tmp_path): """Test when notification database is very small.""" - mod = _get_module() + mod = _get_module(tmp_path) def mock_walk(path): # Return empty - no database files diff --git a/tests/test_module_password_manager_check.py b/tests/test_module_password_manager_check.py new file mode 100644 index 0000000..1d77d8f --- /dev/null +++ b/tests/test_module_password_manager_check.py @@ -0,0 +1,205 @@ +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import ( + ActionKind, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.registry import discover_modules + +MODULE_NAME = "password_manager_check" + + +def _module_object(mod): + """The module object the registry actually loaded this class from. + + discover_modules imports these under a synthetic "rescue_modules." + name, so patching the "modules.security." dotted path would patch a + second, unrelated module object and silently do nothing -- and that name is + not importable, so patch() by string fails outright. Patch the object. + """ + return sys.modules[type(mod).__module__] + + +def _make_profile(platform=Platform.DARWIN): + return SystemProfile( + platform=platform, + os_name="macOS" if platform == Platform.DARWIN else "Windows 11", + 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" + return next(m for m in discover_modules(modules_dir) if m.name == MODULE_NAME) + + +@pytest.fixture +def mod(tmp_path): + """Module pointed at an empty fixture tree, so nothing on the host leaks in.""" + m = _get_module() + apps = tmp_path / "Applications" + apps.mkdir() + m.app_dirs = [str(apps)] + m.browser_profiles = {} + m._apps_dir = apps + m._tmp = tmp_path + return m + + +def _install_app(mod, bundle_name): + (mod._apps_dir / f"{bundle_name}.app").mkdir() + + +def _add_browser(mod, name): + path = mod._tmp / "browsers" / name + path.mkdir(parents=True) + mod.browser_profiles = {**mod.browser_profiles, name: str(path)} + + +def test_discovered_with_expected_metadata(): + m = _get_module() + assert m.name == MODULE_NAME + assert m.category == "security" + assert m.risk_level == RiskLevel.SAFE + assert Platform.DARWIN in m.platforms + assert Platform.WIN32 in m.platforms + # Read-only: auto mode must never apply anything from this module. + assert getattr(m, "auto_apply", False) is False + for code in ( + "security.password_manager_check.no_password_manager", + "security.password_manager_check.browser_stored_passwords", + "security.password_manager_check.inventory", + ): + assert code in m.emits_codes + + +def test_warns_when_no_manager_installed(mod): + result = mod.check(_make_profile()) + warnings = [f for f in result.findings if f.severity == Severity.WARNING] + assert any(f.data.get("check") == "no_password_manager" for f in warnings) + + +def test_no_warning_when_manager_installed(mod): + _install_app(mod, "Bitwarden") + result = mod.check(_make_profile()) + assert not any( + f.data.get("check") == "no_password_manager" for f in result.findings + ) + inventory = next(f for f in result.findings if f.data.get("check") == "inventory") + assert inventory.data["managers"] == ["Bitwarden"] + + +def test_detects_each_known_manager_by_bundle_name(mod): + _install_app(mod, "1Password 8") + _install_app(mod, "KeePassXC") + result = mod.check(_make_profile()) + inventory = next(f for f in result.findings if f.data.get("check") == "inventory") + assert set(inventory.data["managers"]) == {"1Password", "KeePassXC"} + + +def test_unrelated_apps_are_not_mistaken_for_managers(mod): + for name in ("Calculator", "Notes", "Passwords Are Fun"): + _install_app(mod, name) + result = mod.check(_make_profile()) + inventory = next(f for f in result.findings if f.data.get("check") == "inventory") + assert inventory.data["managers"] == [] + + +def test_browser_passwords_flagged_only_without_a_manager(mod): + _add_browser(mod, "Google Chrome") + result = mod.check(_make_profile()) + assert any( + f.data.get("check") == "browser_stored_passwords" for f in result.findings + ) + + _install_app(mod, "Bitwarden") + result = mod.check(_make_profile()) + assert not any( + f.data.get("check") == "browser_stored_passwords" for f in result.findings + ) + + +def test_inventory_is_always_reported(mod): + result = mod.check(_make_profile()) + inventory = [f for f in result.findings if f.data.get("check") == "inventory"] + assert len(inventory) == 1 + assert inventory[0].severity == Severity.INFO + + +def test_windows_uses_the_uninstall_registry(mod): + class FakeResult: + ok = True + stdout = ( + "HKEY_LOCAL_MACHINE\\Software\\...\\{guid}\n" + " DisplayName REG_SZ 1Password 8\n" + " DisplayName REG_SZ Some Unrelated Tool\n" + ) + + with patch.object(_module_object(mod), "run", return_value=FakeResult()): + result = mod.check(_make_profile(Platform.WIN32)) + + inventory = next(f for f in result.findings if f.data.get("check") == "inventory") + assert inventory.data["managers"] == ["1Password"] + + +def test_windows_registry_failure_is_tolerated(mod): + class FailedResult: + ok = False + stdout = "" + + with patch.object(_module_object(mod), "run", return_value=FailedResult()): + result = mod.check(_make_profile(Platform.WIN32)) + + # A failed reg query means "found nothing", not a crash. + inventory = next(f for f in result.findings if f.data.get("check") == "inventory") + assert inventory.data["managers"] == [] + + +def test_unsupported_platform_says_so_rather_than_reporting_healthy(mod): + result = mod.check(_make_profile(Platform.LINUX)) + assert result.supported is False + assert result.unsupported_reason + assert result.findings == [] + + +def test_missing_application_directory_is_tolerated(mod): + mod.app_dirs = [str(mod._tmp / "definitely_absent")] + result = mod.check(_make_profile()) + inventory = next(f for f in result.findings if f.data.get("check") == "inventory") + assert inventory.data["managers"] == [] + + +def test_fix_is_guidance_only(mod): + _add_browser(mod, "Google Chrome") + check = mod.check(_make_profile()) + fix = mod.fix(check, Mode.AUTO) + + assert fix.actions + for action in fix.actions: + assert action.kind == ActionKind.GUIDANCE + # Guidance must never be reported as a completed system change. + assert action.executed is False + assert any("password manager" in a.title.lower() for a in fix.actions) + assert any("browser" in a.title.lower() for a in fix.actions) + + +def test_fix_never_asks_for_a_master_password(mod): + check = mod.check(_make_profile()) + fix = mod.fix(check, Mode.AUTO) + combined = " ".join(a.description for a in fix.actions).lower() + # The guidance must actively warn against entering it, not solicit it. + assert "never type your master password into this tool" in combined 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_security_baseline_diff.py b/tests/test_module_security_baseline_diff.py new file mode 100644 index 0000000..cff4e17 --- /dev/null +++ b/tests/test_module_security_baseline_diff.py @@ -0,0 +1,293 @@ +import json +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import ( + ActionKind, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.registry import discover_modules + +MODULE_NAME = "security_baseline_diff" + + +def _module_object(mod): + return sys.modules[type(mod).__module__] + + +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" + return next(m for m in discover_modules(modules_dir) if m.name == MODULE_NAME) + + +class _Result: + def __init__(self, ok=False, stdout=""): + self.ok = ok + self.stdout = stdout + self.stderr = "" + self.timed_out = False + self.error = None + + +@pytest.fixture +def mod(tmp_path): + m = _get_module() + m.state_dir = tmp_path / "state" + persistence = tmp_path / "LaunchAgents" + persistence.mkdir() + extensions = tmp_path / "Extensions" + extensions.mkdir() + m.persistence_dirs = [str(persistence)] + m.extension_dirs = [str(extensions)] + m._persistence = persistence + m._extensions = extensions + m._tmp = tmp_path + return m + + +def _finding(result, check): + return next((f for f in result.findings if f.data.get("check") == check), None) + + +def _check(mod, commands=None): + """Run check() with all external commands stubbed.""" + handler = commands or (lambda args, **kw: _Result(ok=False)) + with patch.object(_module_object(mod), "run", side_effect=handler): + return mod.check(_make_profile()) + + +def test_discovered_with_expected_metadata(): + m = _get_module() + assert m.name == MODULE_NAME + assert m.category == "security" + assert m.risk_level == RiskLevel.SAFE + assert getattr(m, "auto_apply", False) is False + assert Platform.LINUX in m.platforms + + +def test_first_run_establishes_baseline_as_info_not_warning(mod): + """Nothing is wrong on a first run; it must not read as an alert.""" + result = _check(mod) + finding = _finding(result, "baseline_established") + assert finding is not None + assert finding.severity == Severity.INFO + assert not any(f.severity == Severity.WARNING for f in result.findings) + + +def test_first_run_writes_a_baseline_file(mod): + _check(mod) + path = mod.state_dir / "security_baseline.json" + assert path.is_file() + data = json.loads(path.read_text()) + assert data["version"] == 1 + assert "captured_at" in data + + +def test_baseline_is_written_outside_the_repository(mod): + _check(mod) + path = (mod.state_dir / "security_baseline.json").resolve() + repo_root = Path(__file__).parent.parent.resolve() + assert repo_root not in path.parents + + +def test_second_run_with_no_changes_reports_no_change(mod): + _check(mod) + result = _check(mod) + assert _finding(result, "no_change") is not None + assert not any(f.severity == Severity.WARNING for f in result.findings) + + +def test_new_persistence_item_is_reported(mod): + _check(mod) + (mod._persistence / "com.suspicious.agent.plist").write_text("") + result = _check(mod) + + finding = _finding(result, "new_persistence") + assert finding is not None + assert finding.severity == Severity.WARNING + assert any("com.suspicious.agent.plist" in a for a in finding.data["added"]) + + +def test_removed_persistence_item_is_not_reported(mod): + """Only additions matter; reporting removals symmetrically buries the signal.""" + (mod._persistence / "com.existing.plist").write_text("") + _check(mod) + (mod._persistence / "com.existing.plist").unlink() + result = _check(mod) + + assert _finding(result, "new_persistence") is None + assert _finding(result, "no_change") is not None + + +def test_new_browser_extension_is_reported(mod): + _check(mod) + (mod._extensions / "abcdefghijklmnop").mkdir() + result = _check(mod) + + finding = _finding(result, "new_browser_extension") + assert finding is not None + assert finding.severity == Severity.WARNING + + +def test_new_listening_port_is_reported(mod): + def no_ports(args, **kw): + if args[0] == "netstat": + return _Result(ok=True, stdout="Proto Local Address State\n") + return _Result(ok=False) + + def one_port(args, **kw): + if args[0] == "netstat": + return _Result( + ok=True, + stdout="Proto Local Address State\ntcp4 0.0.0.0.4444 LISTEN\n", + ) + return _Result(ok=False) + + _check(mod, no_ports) + result = _check(mod, one_port) + + finding = _finding(result, "new_listening_port") + assert finding is not None + assert finding.severity == Severity.WARNING + + +def test_protection_turned_off_is_critical(mod): + def firewall_on(args, **kw): + if args and args[0].endswith("socketfilterfw"): + return _Result(ok=True, stdout="Firewall is enabled. (State = 1)") + return _Result(ok=False) + + def firewall_off(args, **kw): + if args and args[0].endswith("socketfilterfw"): + return _Result(ok=True, stdout="Firewall is disabled. (State = 0)") + return _Result(ok=False) + + _check(mod, firewall_on) + result = _check(mod, firewall_off) + + finding = _finding(result, "protection_disabled") + assert finding is not None + assert finding.severity == Severity.CRITICAL + assert "Application firewall" in finding.data["disabled"] + + +def test_protection_turned_on_is_not_reported(mod): + """Asymmetry is deliberate: enabling a protection is not a finding.""" + def firewall_off(args, **kw): + if args and args[0].endswith("socketfilterfw"): + return _Result(ok=True, stdout="Firewall is disabled. (State = 0)") + return _Result(ok=False) + + def firewall_on(args, **kw): + if args and args[0].endswith("socketfilterfw"): + return _Result(ok=True, stdout="Firewall is enabled. (State = 1)") + return _Result(ok=False) + + _check(mod, firewall_off) + result = _check(mod, firewall_on) + + assert _finding(result, "protection_disabled") is None + + +def test_protection_becoming_unqueryable_is_not_reported_as_disabled(mod): + """A command that stops working must not masquerade as a disabled protection.""" + def firewall_on(args, **kw): + if args and args[0].endswith("socketfilterfw"): + return _Result(ok=True, stdout="Firewall is enabled. (State = 1)") + return _Result(ok=False) + + _check(mod, firewall_on) + result = _check(mod, lambda args, **kw: _Result(ok=False)) + + assert _finding(result, "protection_disabled") is None + + +def test_corrupt_baseline_is_treated_as_absent(mod): + _check(mod) + (mod.state_dir / "security_baseline.json").write_text("{not json") + result = _check(mod) + # Re-establishes rather than half-comparing against an unreadable file. + assert _finding(result, "baseline_established") is not None + + +def test_baseline_of_a_different_version_is_treated_as_absent(mod): + _check(mod) + path = mod.state_dir / "security_baseline.json" + data = json.loads(path.read_text()) + data["version"] = 999 + path.write_text(json.dumps(data)) + result = _check(mod) + assert _finding(result, "baseline_established") is not None + + +def test_unwritable_state_dir_is_tolerated(mod): + # A file where the directory should be: writing the baseline must fail + # gracefully rather than raising out of check(). + blocked = mod._tmp / "blocked" + blocked.write_text("") + mod.state_dir = blocked / "nested" + result = _check(mod) + finding = _finding(result, "baseline_established") + assert finding is not None + assert finding.data["baseline_path"] is None + + +def test_every_finding_states_the_trust_on_first_use_limit(mod): + """The limitation must be visible in the report, not just the source.""" + result = _check(mod) + assert "already compromised" in result.findings[0].description + + (mod._persistence / "com.new.plist").write_text("") + result = _check(mod) + for finding in result.findings: + assert "already compromised" in finding.description + + +def test_fix_is_guidance_only(mod): + _check(mod) + (mod._persistence / "com.new.plist").write_text("") + check = _check(mod) + fix = mod.fix(check, Mode.AUTO) + + assert fix.actions + for action in fix.actions: + assert action.kind == ActionKind.GUIDANCE + assert action.executed is False + + +def test_fix_warns_against_rebaselining_a_compromised_machine(mod): + check = _check(mod) + fix = mod.fix(check, Mode.AUTO) + action = next(a for a in fix.actions if a.data.get("check") == "rebaseline") + assert "compromised machine" in action.description + + +def test_fix_does_not_tell_users_to_delete_unrecognised_entries(mod): + """Guidance must be identify-first; blind deletion breaks working systems.""" + _check(mod) + (mod._persistence / "com.new.plist").write_text("") + check = _check(mod) + fix = mod.fix(check, Mode.AUTO) + action = next(a for a in fix.actions if a.data.get("check") == "new_persistence") + assert "Identify first" in action.description diff --git a/tests/test_module_session_revocation_scan.py b/tests/test_module_session_revocation_scan.py new file mode 100644 index 0000000..e18770f --- /dev/null +++ b/tests/test_module_session_revocation_scan.py @@ -0,0 +1,258 @@ +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import ( + ActionKind, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.registry import discover_modules + +MODULE_NAME = "session_revocation_scan" + + +def _module_object(mod): + return sys.modules[type(mod).__module__] + + +def _make_profile(platform=Platform.DARWIN): + return SystemProfile( + platform=platform, + os_name="macOS" if platform == Platform.DARWIN else "Windows 11", + 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" + return next(m for m in discover_modules(modules_dir) if m.name == MODULE_NAME) + + +class _NoOutput: + ok = False + stdout = "" + + +@pytest.fixture +def mod(tmp_path): + m = _get_module() + m.browser_profile_roots = {} + m.authorized_keys_path = tmp_path / "absent_authorized_keys" + m._tmp = tmp_path + return m + + +def _add_chromium(mod, browser, profile_names): + root = mod._tmp / browser.replace(" ", "_") + root.mkdir(parents=True, exist_ok=True) + for name in profile_names: + (root / name).mkdir() + mod.browser_profile_roots = {**mod.browser_profile_roots, browser: str(root)} + return root + + +def _write_authorized_keys(mod, lines): + path = mod._tmp / "authorized_keys" + path.write_text("\n".join(lines) + "\n") + mod.authorized_keys_path = path + return path + + +def _finding(result, check): + return next((f for f in result.findings if f.data.get("check") == check), None) + + +def _check(mod, platform=Platform.DARWIN): + """Run check() with system-account lookup stubbed to "nothing found".""" + with patch.object(_module_object(mod), "run", return_value=_NoOutput()): + return mod.check(_make_profile(platform)) + + +def test_discovered_with_expected_metadata(): + m = _get_module() + assert m.name == MODULE_NAME + assert m.category == "security" + assert m.risk_level == RiskLevel.SAFE + assert getattr(m, "auto_apply", False) is False + for code in ( + "security.session_revocation_scan.browser_sessions", + "security.session_revocation_scan.system_accounts", + "security.session_revocation_scan.ssh_authorized_keys", + "security.session_revocation_scan.inventory", + ): + assert code in m.emits_codes + + +def test_clean_machine_reports_inventory_only(mod): + result = _check(mod) + assert _finding(result, "browser_sessions") is None + assert _finding(result, "ssh_authorized_keys") is None + inventory = _finding(result, "inventory") + assert inventory is not None + assert inventory.severity == Severity.INFO + assert inventory.data["browser_profile_count"] == 0 + assert inventory.data["ssh_key_count"] == 0 + + +def test_counts_each_chromium_profile_separately(mod): + _add_chromium(mod, "Google Chrome", ["Default", "Profile 1", "Profile 2"]) + result = _check(mod) + finding = _finding(result, "browser_sessions") + assert finding is not None + assert finding.severity == Severity.WARNING + assert finding.data["profile_count"] == 3 + + +def test_counts_profiles_across_multiple_browsers(mod): + _add_chromium(mod, "Google Chrome", ["Default", "Profile 1"]) + _add_chromium(mod, "Brave", ["Default"]) + result = _check(mod) + finding = _finding(result, "browser_sessions") + assert finding.data["profile_count"] == 3 + assert {b["browser"] for b in finding.data["browsers"]} == { + "Google Chrome", + "Brave", + } + + +def test_absent_browser_root_is_ignored(mod): + mod.browser_profile_roots = {"Google Chrome": str(mod._tmp / "nope")} + result = _check(mod) + assert _finding(result, "browser_sessions") is None + + +def test_counts_authorized_ssh_keys_without_reading_key_material(mod): + _write_authorized_keys( + mod, + [ + "# a comment line", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAISECRETKEYMATERIAL laptop@home", + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQOTHERSECRET desktop@work", + "", + ], + ) + result = _check(mod) + finding = _finding(result, "ssh_authorized_keys") + assert finding is not None + assert finding.data["key_count"] == 2 + + # The key material must never appear in any finding text or data. + blob = " ".join(f.title + f.description + str(f.data) for f in result.findings) + assert "SECRETKEYMATERIAL" not in blob + assert "OTHERSECRET" not in blob + + +def test_comments_and_blank_lines_are_not_counted_as_keys(mod): + _write_authorized_keys(mod, ["# only a comment", "", " "]) + result = _check(mod) + assert _finding(result, "ssh_authorized_keys") is None + + +def test_authorized_keys_reading_is_bounded(mod): + # Far more lines than the cap; the count must stop at the bound rather than + # walking an arbitrarily large file. + _write_authorized_keys(mod, [f"ssh-ed25519 KEY{i} host{i}" for i in range(2000)]) + result = _check(mod) + finding = _finding(result, "ssh_authorized_keys") + assert finding.data["key_count"] == 500 + + +def test_unreadable_authorized_keys_is_tolerated(mod): + mod.authorized_keys_path = mod._tmp # a directory, not a file + result = _check(mod) + assert _finding(result, "ssh_authorized_keys") is None + + +def test_macos_system_accounts_are_listed_by_id(mod): + class Accounts: + ok = True + stdout = ( + "(\n {\n AccountID = \"user@icloud.com\";\n" + " LoggedIn = 1;\n },\n" + " {\n AccountID = \"other@icloud.com\";\n }\n)\n" + ) + + with patch.object(_module_object(mod), "run", return_value=Accounts()): + result = mod.check(_make_profile()) + + finding = _finding(result, "system_accounts") + assert finding is not None + assert set(finding.data["accounts"]) == {"user@icloud.com", "other@icloud.com"} + + +def test_windows_credential_targets_are_listed(mod): + class CmdKey: + ok = True + stdout = ( + "Currently stored credentials:\n" + " Target: LegacyGeneric:target=git:https://github.com\n" + " Type: Generic\n" + " Target: MicrosoftAccount:user=someone@example.com\n" + ) + + with patch.object(_module_object(mod), "run", return_value=CmdKey()): + result = mod.check(_make_profile(Platform.WIN32)) + + finding = _finding(result, "system_accounts") + assert finding is not None + assert len(finding.data["accounts"]) == 2 + + +def test_failed_account_lookup_is_tolerated(mod): + result = _check(mod) + assert _finding(result, "system_accounts") is None + + +def test_inventory_states_provider_sessions_were_not_checked(mod): + result = _check(mod) + inventory = _finding(result, "inventory") + assert inventory.data["provider_sessions_checked"] is False + assert "provider" in inventory.description.lower() + + +def test_unsupported_platform_says_so(mod): + result = _check(mod, Platform.LINUX) + assert result.supported is False + assert result.unsupported_reason + assert result.findings == [] + + +def test_fix_is_guidance_only(mod): + _add_chromium(mod, "Google Chrome", ["Default"]) + _write_authorized_keys(mod, ["ssh-ed25519 KEY laptop@home"]) + check = _check(mod) + fix = mod.fix(check, Mode.AUTO) + + assert fix.actions + for action in fix.actions: + assert action.kind == ActionKind.GUIDANCE + assert action.executed is False + + +def test_fix_puts_password_change_before_sign_out(mod): + """The ordering is the whole point: revoking first lets them back in.""" + check = _check(mod) + fix = mod.fix(check, Mode.AUTO) + order = next(a for a in fix.actions if a.data.get("check") == "revocation_order") + body = order.description.lower() + assert body.index("change the password") < body.index("sign out of all other") + assert "oauth" in body + + +def test_fix_always_includes_the_ordering_guidance(mod): + """Even on a clean machine, the ordering advice is the useful part.""" + check = _check(mod) + fix = mod.fix(check, Mode.AUTO) + assert any(a.data.get("check") == "revocation_order" for a 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_twofa_audit.py b/tests/test_module_twofa_audit.py new file mode 100644 index 0000000..0c1880a --- /dev/null +++ b/tests/test_module_twofa_audit.py @@ -0,0 +1,221 @@ +import plistlib +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import ( + ActionKind, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.registry import discover_modules + +MODULE_NAME = "twofa_audit" + + +def _module_object(mod): + """The module object the registry actually loaded this class from. + + discover_modules imports these under a synthetic "rescue_modules.", + which is not importable by that name, so patch the object rather than a + dotted string. + """ + return sys.modules[type(mod).__module__] + + +def _make_profile(platform=Platform.DARWIN): + return SystemProfile( + platform=platform, + os_name="macOS" if platform == Platform.DARWIN else "Windows 11", + 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" + return next(m for m in discover_modules(modules_dir) if m.name == MODULE_NAME) + + +@pytest.fixture +def mod(tmp_path): + m = _get_module() + apps = tmp_path / "Applications" + apps.mkdir() + m.app_dirs = [str(apps)] + # Default: no signed-in Apple ID, so platform state is unknown. + m.mobileme_plist_path = tmp_path / "absent.plist" + m._apps_dir = apps + m._tmp = tmp_path + return m + + +def _install_app(mod, bundle_name): + (mod._apps_dir / f"{bundle_name}.app").mkdir() + + +def _write_apple_id(mod, accounts): + path = mod._tmp / "MobileMeAccounts.plist" + with open(path, "wb") as f: + plistlib.dump({"Accounts": accounts}, f) + mod.mobileme_plist_path = path + return path + + +def _finding(result, check): + return next((f for f in result.findings if f.data.get("check") == check), None) + + +def test_discovered_with_expected_metadata(): + m = _get_module() + assert m.name == MODULE_NAME + assert m.category == "security" + assert m.risk_level == RiskLevel.SAFE + assert getattr(m, "auto_apply", False) is False + for code in ( + "security.twofa_audit.no_second_factor_capability", + "security.twofa_audit.platform_2fa_unknown", + "security.twofa_audit.inventory", + ): + assert code in m.emits_codes + + +def test_warns_when_no_authenticator_and_no_platform_signal(mod): + result = mod.check(_make_profile()) + warning = _finding(result, "no_second_factor_capability") + assert warning is not None + assert warning.severity == Severity.WARNING + + +def test_no_warning_when_authenticator_installed(mod): + _install_app(mod, "Authy") + result = mod.check(_make_profile()) + assert _finding(result, "no_second_factor_capability") is None + inventory = _finding(result, "inventory") + assert inventory.data["authenticators"] == ["Authy"] + + +def test_detects_multiple_authenticators(mod): + _install_app(mod, "Microsoft Authenticator") + _install_app(mod, "KeePassXC") + result = mod.check(_make_profile()) + inventory = _finding(result, "inventory") + assert set(inventory.data["authenticators"]) == { + "Microsoft Authenticator", + "KeePassXC", + } + + +def test_unrelated_apps_are_not_counted(mod): + _install_app(mod, "Calculator") + _install_app(mod, "Safari") + result = mod.check(_make_profile()) + inventory = _finding(result, "inventory") + assert inventory.data["authenticators"] == [] + + +def test_apple_id_with_two_factor_flag_is_reported_true(mod): + _write_apple_id(mod, [{"AccountID": "user@icloud.com", "SecureAccount": True}]) + result = mod.check(_make_profile()) + inventory = _finding(result, "inventory") + assert inventory.data["platform_2fa"] is True + # A positive platform signal clears the "no capability" warning. + assert _finding(result, "no_second_factor_capability") is None + + +def test_absent_apple_id_is_unknown_not_disabled(mod): + """The critical honesty property: never claim 2FA is off without evidence.""" + result = mod.check(_make_profile()) + inventory = _finding(result, "inventory") + assert inventory.data["platform_2fa"] is None + assert _finding(result, "platform_2fa_unknown") is not None + + +def test_apple_id_without_flag_is_unknown_not_disabled(mod): + _write_apple_id(mod, [{"AccountID": "user@icloud.com"}]) + result = mod.check(_make_profile()) + inventory = _finding(result, "inventory") + # Apple does not reliably expose this offline, so absence of the flag must + # not be reported as "two-factor is disabled". + assert inventory.data["platform_2fa"] is None + assert inventory.data["platform_2fa"] is not False + + +def test_unreadable_apple_id_plist_is_unknown(mod): + bad = mod._tmp / "corrupt.plist" + bad.write_bytes(b"this is not a plist") + mod.mobileme_plist_path = bad + result = mod.check(_make_profile()) + inventory = _finding(result, "inventory") + assert inventory.data["platform_2fa"] is None + + +def test_inventory_always_states_accounts_were_not_checked(mod): + result = mod.check(_make_profile()) + inventory = _finding(result, "inventory") + assert inventory.data["account_status_checked"] is False + assert "NOT CHECKED" in inventory.description + + +def test_windows_hello_policy_detected(mod): + class Ok: + ok = True + stdout = "HKEY_LOCAL_MACHINE\\SOFTWARE\\Policies\\Microsoft\\PassportForWork\n" + + with patch.object(_module_object(mod), "run", return_value=Ok()): + result = mod.check(_make_profile(Platform.WIN32)) + + inventory = _finding(result, "inventory") + assert inventory.data["platform_2fa"] is True + + +def test_windows_query_failure_is_unknown_not_disabled(mod): + class Failed: + ok = False + stdout = "" + + with patch.object(_module_object(mod), "run", return_value=Failed()): + result = mod.check(_make_profile(Platform.WIN32)) + + inventory = _finding(result, "inventory") + assert inventory.data["platform_2fa"] is None + + +def test_unsupported_platform_says_so(mod): + result = mod.check(_make_profile(Platform.LINUX)) + assert result.supported is False + assert result.unsupported_reason + assert result.findings == [] + + +def test_fix_is_guidance_only_and_orders_email_first(mod): + check = mod.check(_make_profile()) + fix = mod.fix(check, Mode.AUTO) + + assert fix.actions + for action in fix.actions: + assert action.kind == ActionKind.GUIDANCE + assert action.executed is False + + setup = next(a for a in fix.actions if "two-factor" in a.title.lower()) + body = setup.description.lower() + # Email must come before banking: everything else resets through it. + assert body.index("email") < body.index("banking") + assert "sim swap" in body + + +def test_fix_never_solicits_a_code(mod): + check = mod.check(_make_profile()) + fix = mod.fix(check, Mode.AUTO) + combined = " ".join(a.description for a in fix.actions).lower() + assert "never type a one-time code or a recovery code into this tool" in combined diff --git a/tests/test_module_user_profile_size.py b/tests/test_module_user_profile_size.py index aba1d48..52a8e41 100644 --- a/tests/test_module_user_profile_size.py +++ b/tests/test_module_user_profile_size.py @@ -1,12 +1,21 @@ import sys from pathlib import Path -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + +import pytest sys.path.insert(0, str(Path(__file__).parent.parent)) from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode from rescue.registry import discover_modules +# du -sk reports 1024-byte blocks, so sizes below are expressed in blocks. +BLOCKS_PER_GB = 1024**3 // 1024 +SMALL = 1 * BLOCKS_PER_GB # under both thresholds +BIG_USER = 60 * BLOCKS_PER_GB # over the 50 GB user-profile threshold +BIG_LIBRARY = 12 * BLOCKS_PER_GB # over the 10 GB Library threshold +MID_USER = 20 * BLOCKS_PER_GB # under the user threshold + def _make_profile(): return SystemProfile( @@ -20,12 +29,6 @@ def _make_profile(): ) -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 == "user_profile_size") - - def _make_subprocess_result(stdout="", stderr="", returncode=0): result = MagicMock() result.stdout = stdout @@ -34,205 +37,171 @@ def _make_subprocess_result(stdout="", stderr="", returncode=0): return result -def _make_path_mock(dirs_to_return=None, exists_return=True): - """Create a mock for Path operations.""" - if dirs_to_return is None: - dirs_to_return = [] - - def mock_path_init(self, path): - self._path = str(path) - - def mock_iterdir(self): - return dirs_to_return - - def mock_exists(self): - return exists_return - - def mock_truediv(self, other): - # Support path / "subdir" - new_path = MagicMock(spec=Path) - new_path.__truediv__ = mock_truediv.__get__(new_path, type(new_path)) - new_path.exists = mock_exists - new_path.iterdir = mock_iterdir - new_path.name = str(other) - new_path.__str__ = lambda x: str(self._path) + "/" + str(other) - return new_path - - def mock_is_dir(follow_symlinks=True): - return True - - mock = MagicMock(spec=Path) - mock.__truediv__ = mock_truediv - mock.iterdir = mock_iterdir - mock.exists = mock_exists - return mock - - -def _fake_run_small_dirs(): - """All directories are small (no warnings)""" - - def fake_run(cmd, **kwargs): - if isinstance(cmd, list): - cmd_str = " ".join(cmd) - else: - cmd_str = cmd - - if "du" in cmd_str: - # Return small sizes (1 GB each) - return _make_subprocess_result("1048576\t/Users/testuser\n") - return _make_subprocess_result() - - return fake_run - - -def _fake_run_large_user_dir(): - """User directory is over 50GB threshold""" - - def fake_run(cmd, **kwargs): - if isinstance(cmd, list): - cmd_str = " ".join(cmd) - else: - cmd_str = cmd - - if "du" in cmd_str: - # Return size over 50GB (60GB = 61440000 blocks of 1024 bytes) - # Match any /Users/X path (whether X is testuser, annhoward, or other) - if "/Users/" in cmd_str and "Library" not in cmd_str: - return _make_subprocess_result("62914560\t/Users/user\n") - # Library is smaller - elif "Library" in cmd_str: - return _make_subprocess_result("5242880\t/Users/user/Library\n") - # Subdirectories - else: - return _make_subprocess_result("1048576\t/Users/user/Desktop\n") - return _make_subprocess_result() - - return fake_run +@pytest.fixture +def tree(tmp_path): + """A fake /Users tree plus a fake home directory. + + The module walks the real filesystem, so the test supplies a fixture tree + and points the module's roots at it. Nothing here depends on the host OS — + these tests must pass on Linux CI as well as macOS. + """ + users = tmp_path / "Users" + home = users / "alice" + for name in ("alice", "bob"): + (users / name).mkdir(parents=True) + # Directories the module is expected to skip. + (users / "Shared").mkdir() + (users / "Guest").mkdir() + (users / ".hidden").mkdir() + # A plain file must not be mistaken for a user profile. + (users / "notadir").write_text("") + for sub in ("Library", "Desktop", "Documents", "Downloads"): + (home / sub).mkdir() + return {"users": users, "home": home} + + +def _get_module(tree): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + mod = next(m for m in modules if m.name == "user_profile_size") + mod.users_root = tree["users"] + mod.home_root = tree["home"] + return mod -def _fake_run_large_library(): - """Library directory is over 10GB threshold""" +def _fake_du(tree, user_blocks=SMALL, library_blocks=SMALL, subdir_blocks=SMALL): + """Fake `du -sk ` that dispatches on the path it is asked about.""" + users_root = tree["users"] def fake_run(cmd, **kwargs): - if isinstance(cmd, list): - cmd_str = " ".join(cmd) + if not isinstance(cmd, list) or "du" not in cmd[0]: + return _make_subprocess_result() + target = Path(cmd[-1]) + if target.name == "Library": + blocks = library_blocks + elif target.parent == users_root: + blocks = user_blocks else: - cmd_str = cmd - - if "du" in cmd_str: - # Library is over 10GB (12GB = 12582912 blocks of 1024 bytes) - if "Library" in cmd_str: - return _make_subprocess_result("12582912\t/Users/user/Library\n") - # User dir is moderate size - elif "/Users/" in cmd_str: - return _make_subprocess_result("20971520\t/Users/user\n") - else: - return _make_subprocess_result("1048576\t/Users/user/Desktop\n") - return _make_subprocess_result() - - return fake_run - - -def _fake_run_subprocess_error(): - """Subprocess calls fail""" - - def fake_run(cmd, **kwargs): - return _make_subprocess_result(returncode=1) + blocks = subdir_blocks + return _make_subprocess_result(f"{blocks}\t{target}\n") return fake_run def test_user_profile_size_discovered(): - """Test that the module is discovered.""" - mod = _get_module() + modules_dir = Path(__file__).parent.parent / "modules" + mod = next( + m for m in discover_modules(modules_dir) if m.name == "user_profile_size" + ) assert mod.name == "user_profile_size" assert mod.category == "performance" assert mod.risk_level == RiskLevel.SAFE -def test_user_profile_size_small_dirs(): - """Test when all directories are small (no warnings).""" - mod = _get_module() - - # Just patch subprocess to return small sizes - with patch("subprocess.run", side_effect=_fake_run_small_dirs()): +def test_user_profile_size_small_dirs(tree): + """Small directories produce an inventory but no size warnings.""" + mod = _get_module(tree) + with patch("subprocess.run", side_effect=_fake_du(tree)): result = mod.check(_make_profile()) - # Should have findings but no warnings about large directories - assert result.has_issues - # Should have INFO findings about user summary and breakdown assert any(f.severity == Severity.INFO for f in result.findings) + assert not any(f.severity == Severity.WARNING for f in result.findings) + assert not any(f.data.get("type") == "large_user_dir" for f in result.findings) + assert not any(f.data.get("type") == "library_bloat" for f in result.findings) -def test_user_profile_size_large_user_warning(): - """Test WARNING when user directory is over 50GB.""" - mod = _get_module() - - # Just patch subprocess to return large sizes for /Users/* directories - with patch("subprocess.run", side_effect=_fake_run_large_user_dir()): +def test_user_profile_size_large_user_warning(tree): + """A profile over the 50 GB threshold raises a WARNING.""" + mod = _get_module(tree) + with patch("subprocess.run", side_effect=_fake_du(tree, user_blocks=BIG_USER)): result = mod.check(_make_profile()) - # Should have WARNING about large user directory assert result.has_issues - assert any(f.severity == Severity.WARNING for f in result.findings) - assert any("large_user_dir" == f.data.get("type") for f in result.findings) - - -def test_user_profile_size_large_library_warning(): - """Test WARNING when Library directory is over 10GB.""" - mod = _get_module() - - # Just patch subprocess to return large Library sizes - with patch("subprocess.run", side_effect=_fake_run_large_library()): + large = [f for f in result.findings if f.data.get("type") == "large_user_dir"] + assert large + assert all(f.severity == Severity.WARNING for f in large) + # Both real user profiles are over the threshold; skipped dirs are not counted. + assert {f.data["user_name"] for f in large} == {"alice", "bob"} + + +def test_user_profile_size_large_library_warning(tree): + """A Library over the 10 GB threshold raises a WARNING.""" + mod = _get_module(tree) + fake = _fake_du(tree, user_blocks=MID_USER, library_blocks=BIG_LIBRARY) + with patch("subprocess.run", side_effect=fake): result = mod.check(_make_profile()) - # Should have WARNING about large Library directory assert result.has_issues - assert any(f.severity == Severity.WARNING for f in result.findings) - assert any("library_bloat" == f.data.get("type") for f in result.findings) - + bloat = [f for f in result.findings if f.data.get("type") == "library_bloat"] + assert bloat + assert bloat[0].severity == Severity.WARNING + # The moderate user profiles must not also be flagged. + assert not any(f.data.get("type") == "large_user_dir" for f in result.findings) -def test_user_profile_size_info_findings(): - """Test that INFO findings are created for user summary.""" - mod = _get_module() - # Just patch subprocess to return small sizes - with patch("subprocess.run", side_effect=_fake_run_small_dirs()): +def test_user_profile_size_info_findings(tree): + """The inventory and per-subdirectory breakdown are both reported.""" + mod = _get_module(tree) + with patch("subprocess.run", side_effect=_fake_du(tree)): result = mod.check(_make_profile()) - # Should have user summary and breakdown findings - assert any(f.data.get("type") == "user_summary" for f in result.findings) + summary = next( + (f for f in result.findings if f.data.get("type") == "user_summary"), None + ) + assert summary is not None + assert summary.data["user_count"] == 2 assert any(f.data.get("type") == "subdir_breakdown" for f in result.findings) -def test_user_profile_size_fix_is_informational(): - """Test that fix() returns informational actions.""" - mod = _get_module() - - # Just patch subprocess to return large sizes - with patch("subprocess.run", side_effect=_fake_run_large_user_dir()): +def test_user_profile_size_fix_is_informational(tree): + """fix() only ever advises; it never reports a system change.""" + mod = _get_module(tree) + with patch("subprocess.run", side_effect=_fake_du(tree, user_blocks=BIG_USER)): check = mod.check(_make_profile()) - fix = mod.fix(check, Mode.AUTO) + with patch("subprocess.run", side_effect=_fake_du(tree)) as run: + fix = mod.fix(check, Mode.AUTO) - # All actions should succeed (informational) assert fix.all_succeeded assert len(fix.actions) > 0 + # Advisory only: fix() must not shell out to change anything. + assert run.call_count == 0 -def test_user_profile_size_skips_system_dirs(): - """Test that system directories are skipped.""" - mod = _get_module() - - # Just patch subprocess to return small sizes - # The module will iterate over real /Users directories, but Shared and Guest - # should be skipped by the module logic - with patch("subprocess.run", side_effect=_fake_run_small_dirs()): +def test_user_profile_size_skips_system_dirs(tree): + """Shared, Guest, dotfiles, and plain files are not counted as profiles.""" + mod = _get_module(tree) + with patch("subprocess.run", side_effect=_fake_du(tree)): result = mod.check(_make_profile()) - # Should have user summary - summary = next((f for f in result.findings if f.data.get("type") == "user_summary"), None) + summary = next( + (f for f in result.findings if f.data.get("type") == "user_summary"), None + ) assert summary is not None - # The count should not include Shared or Guest - # We can't guarantee the exact count, but it should be at least 1 - assert summary.data.get("user_count") >= 1 + assert summary.data["user_count"] == 2 + listed = summary.description + for skipped in ("Shared", "Guest", ".hidden", "notadir"): + assert skipped not in listed + + +def test_user_profile_size_handles_missing_users_root(tmp_path): + """A machine with no /Users yields no findings rather than an error.""" + modules_dir = Path(__file__).parent.parent / "modules" + mod = next( + m for m in discover_modules(modules_dir) if m.name == "user_profile_size" + ) + mod.users_root = tmp_path / "does_not_exist" + mod.home_root = tmp_path / "also_missing" + result = mod.check(_make_profile()) + assert result.findings == [] + + +def test_user_profile_size_survives_du_failure(tree): + """A failing du is tolerated; sizes fall back to zero.""" + mod = _get_module(tree) + with patch( + "subprocess.run", side_effect=lambda *a, **k: _make_subprocess_result(returncode=1) + ): + result = mod.check(_make_profile()) + + assert not any(f.severity == Severity.WARNING for f in 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_safe_mode_check.py b/tests/test_module_win_safe_mode_check.py index 19c1603..ad5b3ee 100644 --- a/tests/test_module_win_safe_mode_check.py +++ b/tests/test_module_win_safe_mode_check.py @@ -1,4 +1,5 @@ import json +from datetime import datetime, timedelta import sys from pathlib import Path from unittest.mock import patch, MagicMock @@ -9,6 +10,17 @@ from rescue.registry import discover_modules +def _boot_time_days_ago(days: float) -> str: + """An ISO boot timestamp `days` before now. + + The module computes uptime against datetime.now(), so these fixtures must be + relative. They were originally hardcoded absolute dates, which silently + became wrong once real time moved past them: a "5 days ago" fixture aged + into a >30-day uptime and started tripping the high-uptime warning. + """ + return (datetime.now() - timedelta(days=days)).isoformat(timespec="seconds") + + def _make_profile(): return SystemProfile( platform=Platform.WIN32, @@ -87,7 +99,7 @@ def fake_run(cmd, **kwargs): result.stdout = json.dumps(uptime_data) else: # Default to 5 days ago - result.stdout = json.dumps({"LastBootUpTime": "2026-07-02T10:00:00"}) + result.stdout = json.dumps({"LastBootUpTime": _boot_time_days_ago(5)}) return result @@ -155,7 +167,7 @@ def test_win_safe_mode_check_high_uptime(): """Test detection of high uptime (>30 days).""" mod = _get_module() # 35 days ago - fake_run = _make_run_result(last_boot_time="2026-06-02T10:00:00") + fake_run = _make_run_result(last_boot_time=_boot_time_days_ago(35)) with patch("subprocess.run", side_effect=fake_run): result = mod.check(_make_profile()) assert result.has_issues @@ -169,7 +181,7 @@ def test_win_safe_mode_check_low_uptime(): """Test system with low uptime (<30 days).""" mod = _get_module() # 5 days ago - fake_run = _make_run_result(last_boot_time="2026-07-02T10:00:00") + fake_run = _make_run_result(last_boot_time=_boot_time_days_ago(5)) with patch("subprocess.run", side_effect=fake_run): result = mod.check(_make_profile()) # Should not have high_uptime warning @@ -182,7 +194,7 @@ def test_win_safe_mode_check_multiple_issues(): fake_run = _make_run_result( current_safeboot="minimal", bootmgr_safeboot=True, - last_boot_time="2026-06-01T10:00:00", # 36 days ago + last_boot_time=_boot_time_days_ago(36), ) with patch("subprocess.run", side_effect=fake_run): result = mod.check(_make_profile()) @@ -221,7 +233,7 @@ def test_win_safe_mode_check_fix_safeboot_default(): def test_win_safe_mode_check_fix_high_uptime(): """Test fix recommendation for high uptime.""" mod = _get_module() - fake_run = _make_run_result(last_boot_time="2026-05-30T10:00:00") # 38 days ago + fake_run = _make_run_result(last_boot_time=_boot_time_days_ago(38)) with patch("subprocess.run", side_effect=fake_run): check = mod.check(_make_profile()) fix = mod.fix(check, Mode.MANUAL) diff --git a/tests/test_module_win_scheduled_tasks_security.py b/tests/test_module_win_scheduled_tasks_security.py index f29b3b6..43bb6cb 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 diff --git a/tests/update/test_verify.py b/tests/update/test_verify.py index 3b4a206..f84c6c3 100644 --- a/tests/update/test_verify.py +++ b/tests/update/test_verify.py @@ -234,7 +234,12 @@ def test_verification_succeeds_without_the_signing_key_in_the_ambient_keyring( sign_env = {**os.environ, "GNUPGHOME": signing_gnupghome} tag_result = subprocess.run( [ - "git", "-c", f"user.signingkey={key_id}", "-c", "gpg.program=gpg", + # gpg.format must be pinned: git honours the ambient setting, so on + # a machine configured for SSH commit signing (gpg.format = ssh) + # `tag -s` quietly produces an SSH signature and this test ends up + # exercising the SSH path while asserting on the GPG one. + "git", "-c", "gpg.format=openpgp", + "-c", f"user.signingkey={key_id}", "-c", "gpg.program=gpg", "tag", "-s", "approved/maintainer-a/1", "-m", "approved", commit_sha, ], cwd=origin, capture_output=True, text=True, env=sign_env,