Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion desktop/main.js
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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(); });
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
17 changes: 17 additions & 0 deletions desktop/setting-urls.js
Original file line number Diff line number Diff line change
@@ -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 };
54 changes: 54 additions & 0 deletions desktop/test/setting-urls.test.js
Original file line number Diff line number Diff line change
@@ -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,<script>alert(1)</script>',
'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);
}
});
114 changes: 114 additions & 0 deletions docs/ROADMAP_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
54 changes: 54 additions & 0 deletions guides/home_network_intrusion/phase_0.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions guides/home_network_intrusion/phase_1.md
Original file line number Diff line number Diff line change
@@ -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.
Loading