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,