From 2f7c4354fcce113a373734920e8e2bee988c811b Mon Sep 17 00:00:00 2001 From: Heznpc Date: Wed, 19 Aug 2026 16:35:53 +0900 Subject: [PATCH] Audit what stayed behind after an uninstall (moraine) Removing software does not remove what it registered, and the leftovers that matter are the ones that keep acting on the machine after the app is gone. Two records outlive the uninstall and neither is a file sweep: pkgutil receipts every package ever installed, and whether its payload still exists trust domains root certificates in the admin/user stores, and how broadly each is trusted The verdict is the correlation. A trusted root whose installing package has no payload left is a certificate still vouching for a vendor otherwise gone from the machine, and neither source produces that alone. First live run on this Mac found exactly that: INNORIX.CA, self-signed, CA:TRUE, 2048-bit, valid to 2028-07-07, trusted unconditionally in the admin domain -- installed 2026-05-01 by a Korean file-transfer plugin whose app, LaunchAgent, and processes are all gone. macOS trust semantics are the trap here: an empty usage-constraint array reads like "nothing configured" and means the opposite, trust as root for every policy, so it is named explicitly and pinned by a test. A root with no owning receipt is reported "unattributed", never accused -- MDM profiles, enterprise Wi-Fi, and hand-imported roots legitimately have no installer receipt. Large packages are file-sampled and any sampling is reported. Nothing is written and nothing deleted; removing a trust root is an admin act that stays a human decision. Prior art was read rather than assumed. AppCleaner's location list is genuinely vetted (BundlePaths.plist plus ~70 directories in its binary), but it is target-driven -- you drop an app on it -- and its binary carries no SecTrust or SecCertificate symbols, so trust-store contents are outside a file sweep. mole reads pkgutil receipts too, in lib/core/pkg_receipts.sh, but only to locate app bundles under /usr/local and /opt, never to judge whether a payload survived. Also corrects a claim in the README. Orca's workspace cleanup runs the same rev-list --count HEAD --not --remotes check scree does, against the worktrees it manages, so "no other tool asks" was false for the unpushed-work verdict. The README now names the prior art and states the actual difference: scree sweeps every agent worktree on the machine whatever created it, plus primary checkouts stranded off main and registry entries whose directory vanished. The judgment itself stays -- it is what cleanup's safety rests on, and the canary absorption rested on scree already having it, neither of which depends on nobody else having it too. Exposed through the MCP surface as moraine_report, which the registration-time read-only contract gates like the other three. --- CHANGELOG.md | 5 + README.md | 23 +- scripts/mcp_server.py | 78 ++++- scripts/moraine.py | 505 ++++++++++++++++++++++++++++++++ scripts/release_smoke.py | 1 + tests/test_mcp_server.py | 53 +++- tests/test_moraine.py | 325 ++++++++++++++++++++ tests/test_release_hardening.py | 3 +- tests/test_service_contracts.py | 1 + 9 files changed, 981 insertions(+), 13 deletions(-) create mode 100644 scripts/moraine.py create mode 100644 tests/test_moraine.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a3bbe1d..aee42d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project are documented here. Format loosely follows ## [Unreleased] ### Added +- **Leave-behind audit — `scripts/moraine.py`.** Reads the two records that outlive an uninstall — macOS installer receipts (`pkgutil`) and the admin/user trust domains (`security dump-trust-settings`) — and correlates them into a verdict neither produces alone: a trusted root certificate whose installing package has no payload left on disk. First live run on the development Mac found exactly that — `INNORIX.CA`, a self-signed `CA:TRUE` root valid to 2028-07-07, trusted unconditionally in the admin domain, installed 2026-05-01 by a Korean file-transfer plugin whose app, LaunchAgent, and processes are all gone. Receipts are judged `present`/`partial`/`vanished`/`no_payload` with any file sampling reported; roots with no owning receipt are `unattributed` (MDM profiles and enterprise Wi-Fi legitimately have none), never accused. Read-only: nothing is written and nothing deleted — removing a trust root is an admin act that stays a human decision. Exposed through the MCP surface as `moraine_report`. + +### Changed +- **The scree pitch no longer claims the unpushed-work verdict is unique.** Orca's workspace cleanup runs the same `rev-list --not --remotes` check against the worktrees it manages, verified in its source. README now states the actual difference — scree sweeps every agent worktree on the machine whatever created it, plus primary checkouts stranded off main and registry entries whose directory vanished — and names the prior art instead of implying none exists. + - **Operator-friction scan — `scripts/friction.py`.** Deterministic port of canary's `lib/sessions/friction.ts` (that repository is now a frozen paper archive; this was its one capability Modore did not already have). Flags the user turns where the operator pushed back on agent behaviour, across nine categories — wrong-action, no-research-assertion, stalling-approval, rule-contamination, over-orchestration-token, stale-repetition, verbosity, tone-attitude, other-ai-friction — at severity 1-3. Keyword and tone matching only, no model in the judgment path. Claude Code and Codex sessions are discovered through scree's own collectors rather than a second traversal; Gemini CLI chats (`~/.gemini/tmp/*/chats`, joined to a workspace through the project registry) and Claude Desktop local-agent sessions are added because scree does not traverse either — its Gemini collector reads only the project registry, and it has no Claude Desktop collector. Content contract: user-authored turns only, quotes capped at 200 characters and masked by default (`--raw-quotes` opts out), nothing written. `--json` serves the structured report. First live run over a 30-day window: 200 sessions, 3,563 user turns, 430 findings (rage 247 / irritation 60 / correction 123). - **Read-only MCP surface — `scripts/mcp_server.py`.** A zero-dependency stdio JSON-RPC server exposing three tools: `scree_report` (join, retention forecast, sole-copy and orphan verdicts, by section), `friction_scan` (the taxonomy above, filterable by store/category/minimum severity), and `system_scan_summary` (the storage and security scan result already on disk, with its age). Each tool runs `scree.py --json` or `friction.py --json` and forwards the output, so no judgment is reimplemented and the CLI, the Mac app, and the MCP surface cannot disagree. Cleanup, deletion, and scan execution are deliberately not exposed — Modore's destructive path is gated on an on-screen human approval, and an agent-reachable bypass would void it; pinned by a test that exercises every tool and asserts the only processes ever spawned are the two judgment scripts. The read-only contract is enforced where tools are registered, ported from AirMCP's own Swift MCP server (`IOSPreviewContract`): a tool is reachable only if it is on an explicit name allowlist and annotated read-only and non-destructive, so one added without a deliberate edit fails closed rather than being merely unlisted. All results are fenced as untrusted machine-derived data. - **scree lineage: what the sessions remember versus what the disk still holds.** Every work path appearing in session records is classified alive+git / alive+plain / vanished, using existence and a `.git` presence check only; macOS case-variant ghosts (the same path recorded under different casings) are merged by casefold and reported once with their spellings. First live run: 422 paths — 118 alive git repos, 189 alive plain directories, 115 vanished (session records their only trace), 4 case ghosts. Consumers such as balance's lineup-gap diff read `--json .lineage`; scree itself reports universal facts only. diff --git a/README.md b/README.md index 959a587..2964d75 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Modore -> **A local audit of what your AI agents left behind.** On a Mac where Claude Code, Codex, Gemini, or an AI IDE has been working, Modore answers the questions no other tool asks: which sessions touched which projects, which transcripts silently expire in a few days, which agent worktrees hold the only copy of unpushed work, and which paths survive nowhere except in a session record — deterministically, metadata-only, with no LLM anywhere in the judgment path. +> **A local audit of what your AI agents left behind.** On a Mac where Claude Code, Codex, Gemini, or an AI IDE has been working, Modore answers the questions nothing else on that machine is asking: which sessions touched which projects, which transcripts silently expire in a few days, which agent worktrees hold the only copy of unpushed work — whichever tool you ran the agent from — and which paths survive nowhere except in a session record — deterministically, metadata-only, with no LLM anywhere in the judgment path. > The same evidence-first approach also covers the PC that feels busy for no reason: process, network, autorun, security, and storage signals turned into plain-language evidence before you stop or delete anything. [🌐 **Website**](https://heznpc.github.io/modore/) · [📦 Releases (when published)](https://github.com/heznpc/modore/releases) · [Architecture](./docs/ARCHITECTURE.md) @@ -15,7 +15,7 @@ ### 1. What did my AI tools leave behind? (Mac) -On a Mac where Claude Code, Codex, Gemini, or an AI IDE has been working, the machine fills with traces no other tool audits: session stores that silently expire on rolling windows, agent git worktrees holding the only copy of unpushed work, orphaned sessions pointing at deleted projects, gigabytes of rebuildable model and editor caches, and paths whose only surviving record is a session transcript. **scree**, Modore's session-and-residue audit, judges all of it — deterministically, metadata-only, with no LLM anywhere in the judgment path: +On a Mac where Claude Code, Codex, Gemini, or an AI IDE has been working, the machine fills with traces nothing audits end to end: session stores that silently expire on rolling windows, agent git worktrees holding the only copy of unpushed work, orphaned sessions pointing at deleted projects, gigabytes of rebuildable model and editor caches, and paths whose only surviving record is a session transcript. **scree**, Modore's session-and-residue audit, judges all of it — deterministically, metadata-only, with no LLM anywhere in the judgment path: ```bash python3 scripts/scree.py # join · retention forecast · orphans · sole-copy verdicts · lineage @@ -26,6 +26,7 @@ python3 scripts/scree.py preserve # masked single-session export - **Retention forecast** — per-store rolling windows estimated from file ages, with D-day flags for sessions about to expire inside still-active projects. - **Sole-copy judgment** — agent worktrees *and* primary checkouts stranded off main: protected (dirty or unpushed commits) versus rebuildable, from read-only git evidence, every verdict preview-grade with an explicit revalidation duty. - **Orphans & lineage** — sessions pointing at vanished workspaces (`orphan_basis: path_missing`), and every remembered work path classified alive+git / alive+plain / vanished, with macOS case-variant ghosts merged. +- **Not a uniqueness claim about the primitive** — an agent IDE that creates worktrees can and does compute the same unpushed-work verdict for the worktrees it manages (Orca's workspace cleanup runs the same `rev-list --not --remotes` check). scree's coverage is the difference: it sweeps every agent worktree on the machine regardless of which tool created it, plus primary checkouts stranded off main and registry entries whose directory is gone. - **Contract** — leading JSONL lines are decoded in memory but message content is never retained or emitted; nested transcripts are attributed by `stat()` without being opened; pinned by tests. `preserve` is the single deliberate exception: one caller-named file, mask-by-default (`--raw` opts out), no bulk export. **friction**, scree's sibling, reads the same four session stores for the opposite question — not what the agents left behind, but where the operator stopped them: @@ -40,6 +41,21 @@ python3 scripts/friction.py scan --json --source codex # structured output - **Same judgment contract** — keyword and tone matching only, no model anywhere in the path; a review aid that both under- and over-catches, so every verdict is tagged `evidence: preview`. - **Content contract** — only turns authored by the user are examined; assistant text, tool calls, and nested subagent transcripts are never emitted. Quotes are capped at 200 characters and masked (email / JWT / API keys / private keys / home path) by default, with `--raw-quotes` as the explicit opt-out. Nothing is written. +### What stayed after you uninstalled it + +Removing an app does not remove what it registered. `moraine` reads the two records that outlive the uninstall — macOS installer receipts and the system/user trust stores — and correlates them: + +```bash +python3 scripts/moraine.py # receipts · trusted roots · orphaned-root verdict +python3 scripts/moraine.py --json +``` + +- **Receipts** — every package ever installed, with whether its payload still exists (`present` / `partial` / `vanished` / `no_payload`) and when it was installed. macOS never forgets a receipt, so it outlives the files. +- **Trusted roots** — every root in the admin and user trust domains, with self-signed / CA / key size / validity, and whether its trust is *unconditional*. An empty usage-constraint array means "trust as root for every policy", which reads like "nothing configured" and is exactly the opposite. +- **The correlated verdict** — a trusted root whose installing package has no payload left is a certificate still vouching for a vendor otherwise gone from the machine. Neither source produces that alone. `unattributed` means no receipt claims the root: MDM profiles, enterprise Wi-Fi, and hand-imported roots are legitimately unattributed, so it is a statement about attribution, not legitimacy. +- **Prior art** — AppCleaner is the reference for moraine hunting and its location list is genuinely vetted, but it is target-driven (you drop an app on it) and its binary contains no `SecTrust`/`SecCertificate` symbols at all; mole reads `pkgutil` receipts too, but only to locate app bundles in `/usr/local` and `/opt`, never to judge whether a payload survived. The trust store is the half neither reaches. +- **Deletes nothing.** Removing a trust root is an admin act and stays a human decision. + ### Both questions, mid-session (MCP) The judgments above were terminal-only, which meant the agent doing the work could not ask them while working. Modore ships a zero-dependency MCP server so it can — before deleting a worktree, before assuming a session will still be there tomorrow, before repeating something the operator already objected to: @@ -55,6 +71,7 @@ python3 scripts/mcp_server.py --tools # inspect the surface without speaking - `scree_report` — the join, retention forecast, orphan/sole-copy/lineage judgment, by section, with every truncation reported. - `friction_scan` — the pushback taxonomy, filterable by store, category, and minimum severity. - `system_scan_summary` — the storage and security scan result *already on disk*, with its age, because a stale result read as current is the failure mode here. +- `moraine_report` — installer receipts, trusted roots, and the orphaned-root verdict above. - **A thin layer, not a second implementation** — each tool runs `scree.py --json` or `friction.py --json` and forwards what it prints, so the CLI, the Mac app, and the MCP surface cannot disagree about what is true. - **Read-only by contract, enforced at registration** — a tool is reachable only if it is on an explicit allowlist and annotated read-only and non-destructive; one added without a deliberate edit fails closed. Cleanup, deletion, and scan execution are deliberately absent. Modore gates destruction on an approval a human grants on screen; an agent-reachable bypass would not be a feature, it would be the end of that guarantee. Every result is fenced as untrusted data. @@ -68,6 +85,7 @@ A fan that will not stop, CPU/GPU load while idle, an unknown process, a strange - **Two OS editions under one brand**: Modore for Windows and Modore for Mac share the same promise — explain local machine state in plain language without deleting anything automatically. - **Mac Edition — AI-agent session audit**: `scree` (above) is the flagship Mac capability — cross-tool join, retention forecast, orphan/sole-copy/lineage judgment, metadata-only. +- **Mac Edition — leave-behind audit**: `moraine` judges what survived an uninstall from installer receipts and the macOS trust store, including root certificates left trusted by software that is no longer installed. - **Mac Edition — operator-friction scan**: `friction` classifies the turns where the operator pushed back on agent behaviour across Claude Code, Codex, Gemini CLI, and Claude Desktop transcripts — nine categories, severity 1-3, deterministic keyword/tone matching, user-authored turns only, quotes masked by default. - **Read-only MCP surface**: a zero-dependency stdio MCP server exposing scree, friction, and the existing storage/security scan summary to an agent mid-session. Judgment only — no cleanup, no deletion, no scan execution. - **Mac Edition scanner**: Bash + JXA collectors for macOS security context, launchd/login items, Gatekeeper/SIP/XProtect, network/listening ports, installed-app size, and developer-runtime incidents. Every collector reports `ok`, `permission_denied`, `unavailable`, `timed_out`, or `failed`; a missing required collector can never become a safe verdict. @@ -238,6 +256,7 @@ modore/ ├── scripts/ │ ├── scree.py AI-agent session & residue audit (metadata-only) │ ├── friction.py operator-pushback scan over the same session stores +│ ├── moraine.py receipts + trust-store audit of what survived an uninstall │ ├── mcp_server.py read-only MCP surface (scree · friction · scan summary) │ ├── menu.ps1 Windows interactive menu │ ├── scanner.ps1 Windows scanner diff --git a/scripts/mcp_server.py b/scripts/mcp_server.py index bc130bd..d5c881a 100644 --- a/scripts/mcp_server.py +++ b/scripts/mcp_server.py @@ -80,9 +80,11 @@ SCREE = SCRIPT_DIR / "scree.py" FRICTION = SCRIPT_DIR / "friction.py" +MORAINE = SCRIPT_DIR / "moraine.py" SCREE_TIMEOUT = 300 FRICTION_TIMEOUT = 300 +MORAINE_TIMEOUT = 300 # scree's full report is large (hundreds of lineage paths on a working machine). # Sections are selectable and lists are truncated, but never silently: every @@ -242,6 +244,47 @@ def tool_friction_scan(args: dict) -> dict: } +MORAINE_SECTIONS = ("summary", "trust_roots", "receipts", "all") + + +def tool_moraine_report(args: dict) -> dict: + section = _enum_arg(args, "section", MORAINE_SECTIONS, "all") + limit = _int_arg(args, "limit", default=10, minimum=1, maximum=200) + report = _run_json(MORAINE, ["--json"], MORAINE_TIMEOUT) + + roots = (report.get("trust_roots") or {}).get("items") or [] + receipts = report.get("receipts") or {} + summary = { + "contract": report.get("contract"), + "evidence": report.get("evidence"), + "prior_art": report.get("prior_art"), + "sources": report.get("sources"), + "receipts_total": receipts.get("total"), + "receipts_non_apple": receipts.get("non_apple"), + "receipts_payload_gone": receipts.get("vanished"), + "trust_roots_total": (report.get("trust_roots") or {}).get("total"), + "trust_roots_orphaned": (report.get("trust_roots") or {}).get("orphaned"), + "trust_roots_unattributed": (report.get("trust_roots") or {}).get("unattributed"), + "trust_roots_unconditional": (report.get("trust_roots") or {}).get("unconditional"), + "removed_vendors": report.get("removed_vendors"), + } + if section == "summary": + return summary + + payload: dict = {"summary": summary} + if section in ("all", "trust_roots"): + # moraine already orders worst-first; truncating a sorted list keeps the + # findings that matter rather than an arbitrary slice. + items, note = _truncate(roots, limit) + payload["trust_roots"] = {"items": items, **note} + if section in ("all", "receipts"): + vendors = [v for v in (receipts.get("vendors") or []) if not v.get("apple")] + items, note = _truncate(vendors, limit) + payload["receipts"] = {"non_apple_vendors": items, "sampled": receipts.get("sampled"), + **note} + return payload + + SCAN_RESULT_CANDIDATES = ( lambda: Path(os.environ["PCH_SCAN"]) if os.environ.get("PCH_SCAN") else None, lambda: PROJECT_ROOT / "scan_result.json", @@ -455,6 +498,38 @@ def _enum_arg(args: dict, name: str, allowed: tuple, default): "annotations": {"title": "System scan summary — storage & security", **READ_ONLY}, "handler": tool_system_scan_summary, }, + { + "name": "moraine_report", + "title": "Moraine — what stayed after the installer left", + "description": ( + "What software left behind on this Mac after it was removed, from two sources " + "that outlive the uninstall: macOS installer receipts (every package ever " + "installed, and whether its payload still exists) and the system/user trust " + "stores (root certificates and how broadly they are trusted). The verdict " + "worth asking for is the correlation: a trusted root whose installing package " + "has no payload left is a certificate still vouching for a vendor otherwise " + "gone from the machine. `unattributed` means no receipt claims the root -- MDM " + "profiles, enterprise Wi-Fi, and hand-imported roots are legitimately " + "unattributed, so it is a statement about attribution, not about legitimacy. " + "Read-only: it deletes nothing, and removing a trust root is an admin act that " + "stays a human decision. macOS only."), + "inputSchema": { + "type": "object", + "properties": { + "section": { + "type": "string", "enum": list(MORAINE_SECTIONS), "default": "all", + "description": ("`summary` is counts only; `trust_roots` is the " + "certificate judgment, worst first; `receipts` is the " + "non-Apple vendor rollup."), + }, + "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 10, + "description": "Max items per list. Truncation is always reported."}, + }, + "additionalProperties": False, + }, + "annotations": {"title": "Moraine — what stayed after the installer left", **READ_ONLY}, + "handler": tool_moraine_report, + }, ] # Read-only contract, ported from AirMCP's iOS server (`IOSPreviewContract` in @@ -465,7 +540,8 @@ def _enum_arg(args: dict, name: str, allowed: tuple, default): # refactor -- is unreachable rather than merely unlisted. Failing closed is the # point: "we simply never wrote a destructive tool" is an intention, and this # turns it into a mechanism. -EXPOSED_TOOL_NAMES = frozenset({"scree_report", "friction_scan", "system_scan_summary"}) +EXPOSED_TOOL_NAMES = frozenset({"scree_report", "friction_scan", "system_scan_summary", + "moraine_report"}) def contract_allows(tool: dict) -> bool: diff --git a/scripts/moraine.py b/scripts/moraine.py new file mode 100644 index 0000000..3678565 --- /dev/null +++ b/scripts/moraine.py @@ -0,0 +1,505 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Moraine, Modore's leave-behind audit: what stays after the installer is gone. + +Uninstalling software rarely removes everything it placed, and the moraines +that matter most are the ones that keep acting on the machine after the app that +put them there no longer exists. + +Two sources, both read-only, both surviving the uninstall they describe: + +- **Installer receipts** (`pkgutil`). macOS records every package ever installed + and never forgets, so the receipt outlives the payload. A receipt whose files + are all gone is the fingerprint of something that was here and was removed. +- **Trust-store roots** (`security dump-trust-settings`). A root certificate a + vendor added is not a file in a folder; it is an entry inside the system + trust store. Deleting the app does not touch it, and a trusted root keeps + validating TLS for as long as it is valid. + +Correlating the two is the point: a *trusted root whose installing package has +no payload left on disk* is a certificate still vouching for a vendor that is +otherwise gone from the machine. That is the verdict this module exists to +produce, and neither source alone can produce it. + +Prior art, and why this is not it: AppCleaner +(https://freemacsoft.net/appcleaner/) is the reference for moraine hunting on +macOS and its location list is genuinely vetted -- its bundled BundlePaths.plist +plus binary string table cover ~70 /Library and ~/Library directories including +LaunchAgents, LaunchDaemons, PrivilegedHelperTools, StartupItems, Extensions, +and /private/var/db/receipts. Two structural differences leave a gap it cannot +close: + +- It is target-driven. You drop an app on it and it finds that app's files. When + the app is already gone there is nothing to drop, and the moraines have no + owner to search from. This runs the other direction, residue first, the + same inversion scree applies to session stores. +- It treats `/Library/Keychains` as a directory of files and receipts as files + to delete. Its binary contains no SecTrust or SecCertificate symbols at all + (checked directly, 2026-08-19), so trust-store *contents* -- the entries that + actually carry authority -- are outside what a file sweep can reach. + +Judgment limits (preview-grade evidence, never deletion authorization): +- attribution links a certificate to a package by vendor token, so a vendor that + names its package differently from its certificate reads as `unattributed` + rather than being guessed at; +- an `unattributed` root is not thereby suspicious: MDM profiles, enterprise + Wi-Fi, and hand-imported roots legitimately have no installer receipt; +- large packages are file-sampled, and any sampling is reported. + +Writes nothing; all output goes to stdout. Deletion is never performed and never +suggested as automatic -- removing a trust root is an admin-authority act that +belongs to the person at the keyboard. +""" +from __future__ import annotations + +import argparse +import calendar +import json +import plistlib +import re +import subprocess +import sys +import time +from pathlib import Path +from typing import Callable, Optional + +PKGUTIL = "/usr/sbin/pkgutil" +SECURITY = "/usr/bin/security" +# The system LibreSSL, not whatever a package manager put earlier on PATH: its +# output format is what the parsers below are written against, and pinning the +# absolute path keeps this deterministic across machines. +OPENSSL = "/usr/bin/openssl" + +COMMAND_TIMEOUT = 60 +# Per-package file check ceiling. Xcode-class receipts list tens of thousands of +# paths; stat()ing all of them for every package turns a report into a disk +# crawl. Sampling is always reported, never silent. +FILE_SAMPLE_CAP = 200 + +Runner = Callable[[list[str], Optional[str]], "tuple[int, str]"] + + +def run_command(args: list[str], stdin_text: Optional[str] = None) -> tuple[int, str]: + try: + proc = subprocess.run(args, capture_output=True, text=True, + timeout=COMMAND_TIMEOUT, + input=stdin_text, + stdin=None if stdin_text is not None else subprocess.DEVNULL) + except (OSError, subprocess.SubprocessError): + return 127, "" + return proc.returncode, proc.stdout + + +# --------------------------------------------------------------------------- +# Installer receipts +# --------------------------------------------------------------------------- + +APPLE_PKG_PREFIXES = ("com.apple.",) + + +def _vendor_of_pkgid(pkgid: str) -> str: + """`com.innorix.innorixexagent.ca.pkg` -> `com.innorix`. + + Reverse-DNS second component is the vendor namespace the vendor itself + chose, which is why this is a read rather than a guess. Ids that do not + look reverse-DNS fall back to the whole id. + """ + parts = pkgid.split(".") + return ".".join(parts[:2]) if len(parts) >= 2 else pkgid + + +def collect_receipts(run: Runner = run_command, *, + sample_cap: int = FILE_SAMPLE_CAP) -> tuple[list[dict], dict]: + code, out = run([PKGUTIL, "--pkgs"], None) + if code != 0: + return [], {"source": "receipts", "status": "unavailable", + "detail": "pkgutil did not run", "count": 0} + pkgids = [line.strip() for line in out.splitlines() if line.strip()] + receipts: list[dict] = [] + unreadable = 0 + for pkgid in sorted(pkgids): + code, info_out = run([PKGUTIL, "--pkg-info-plist", pkgid], None) + if code != 0: + unreadable += 1 + continue + try: + info = plistlib.loads(info_out.encode("utf-8")) + except (ValueError, plistlib.InvalidFileException): + unreadable += 1 + continue + volume = info.get("volume") or "/" + location = info.get("install-location") or "" + root = Path(volume) / location if location else Path(volume) + + code, files_out = run([PKGUTIL, "--files", pkgid], None) + relatives = [line.strip() for line in files_out.splitlines() if line.strip()] \ + if code == 0 else [] + sampled = len(relatives) > sample_cap + checked = relatives[:sample_cap] + present = sum(1 for rel in checked if (root / rel).exists()) + if not checked: + # A payload-free receipt (a script-only package) cannot be judged by + # file presence, so it is reported as its own state rather than + # being folded into "vanished". + state = "no_payload" + elif present == 0: + state = "vanished" + elif present == len(checked): + state = "present" + else: + state = "partial" + install_time = info.get("install-time") + receipts.append({ + "pkgid": pkgid, + "vendor": _vendor_of_pkgid(pkgid), + "apple": pkgid.startswith(APPLE_PKG_PREFIXES), + "version": info.get("pkg-version"), + "volume": volume, + "location": location, + "install_time": install_time, + "installed_on": (time.strftime("%Y-%m-%d %H:%M", time.localtime(install_time)) + if isinstance(install_time, (int, float)) else None), + "files_total": len(relatives), + "files_checked": len(checked), + "files_present": present, + "sampled": sampled, + "state": state, + }) + receipts.sort(key=lambda r: (r["install_time"] or 0), reverse=True) + return receipts, {"source": "receipts", "status": "ok", "count": len(receipts), + "unreadable": unreadable} + + +def summarize_vendors(receipts: list[dict]) -> list[dict]: + """Per-vendor rollup, non-Apple first: the shape a person actually reads.""" + groups: dict[str, dict] = {} + for receipt in receipts: + group = groups.setdefault(receipt["vendor"], { + "vendor": receipt["vendor"], "apple": receipt["apple"], "packages": 0, + "states": {}, "installed_on": None}) + group["packages"] += 1 + group["states"][receipt["state"]] = group["states"].get(receipt["state"], 0) + 1 + if receipt["installed_on"] and (group["installed_on"] is None + or receipt["installed_on"] > group["installed_on"]): + group["installed_on"] = receipt["installed_on"] + for group in groups.values(): + states = group["states"] + living = states.get("present", 0) + states.get("partial", 0) + group["fully_removed"] = living == 0 and states.get("vanished", 0) > 0 + return sorted(groups.values(), + key=lambda g: (g["apple"], not g["fully_removed"], g["vendor"])) + + +# --------------------------------------------------------------------------- +# Trust-store roots +# --------------------------------------------------------------------------- + +_CERT_LINE = re.compile(r"^\s*Cert\s+\d+:\s*(?P.+?)\s*$") +_SETTINGS_COUNT = re.compile(r"^\s*Number of trust settings\s*:\s*(?P\d+)\s*$") +_RESULT_TYPE = re.compile(r"^\s*Result Type\s*:\s*(?P\S+)\s*$") +_POLICY_OID = re.compile(r"^\s*Policy OID\s*:\s*(?P.+?)\s*$") + + +def parse_trust_settings(text: str, domain: str) -> list[dict]: + """Parse one `security dump-trust-settings` dump into per-certificate rows.""" + roots: list[dict] = [] + current: Optional[dict] = None + for line in text.splitlines(): + match = _CERT_LINE.match(line) + if match: + current = {"name": match.group("name"), "domain": domain, + "settings_count": None, "policies": [], "result_types": []} + roots.append(current) + continue + if current is None: + continue + match = _SETTINGS_COUNT.match(line) + if match: + current["settings_count"] = int(match.group("count")) + continue + match = _RESULT_TYPE.match(line) + if match: + current["result_types"].append(match.group("value")) + continue + match = _POLICY_OID.match(line) + if match: + current["policies"].append(match.group("value")) + for root in roots: + # macOS trust-settings semantics: a certificate listed in a trust domain + # with an EMPTY usage-constraints array means "trust as root, for every + # policy". Zero settings is therefore the broadest possible grant, not + # the narrowest -- the reading that looks like "nothing configured" is + # exactly backwards, so it is named explicitly here. + root["unconditional"] = root["settings_count"] == 0 + root["trusted_as_root"] = (root["unconditional"] + or any("TrustAsRoot" in value + for value in root["result_types"])) + return roots + + +_DN_COMPONENT = re.compile(r"(?:^|[/,])\s*(?P[A-Za-z]+)=(?P[^/,]+)") + + +def parse_dn(text: str) -> dict: + """Distinguished name -> dict. Tolerates both the slash-separated form + LibreSSL prints and the comma-separated form OpenSSL 3 prints.""" + body = text.split("=", 1)[1] if text.lower().startswith(("subject=", "issuer=")) else text + return {m.group("key").upper(): m.group("value").strip() + for m in _DN_COMPONENT.finditer(body)} + + +def _parse_openssl_date(text: str) -> Optional[float]: + value = text.split("=", 1)[-1].strip().replace(" GMT", "") + for fmt in ("%b %d %H:%M:%S %Y", "%b %d %H:%M:%S %Y"): + try: + return calendar.timegm(time.strptime(value, fmt)) + except ValueError: + continue + return None + + +def describe_certificate(pem: str, run: Runner, now_ts: float) -> Optional[dict]: + code, out = run([OPENSSL, "x509", "-noout", "-subject", "-issuer", + "-startdate", "-enddate", "-serial"], pem) + if code != 0 or not out.strip(): + return None + fields: dict[str, str] = {} + for line in out.splitlines(): + key, _, value = line.partition("=") + fields[key.strip().lower()] = value.strip() + subject = parse_dn("subject=" + fields.get("subject", "")) + issuer = parse_dn("issuer=" + fields.get("issuer", "")) + not_after = _parse_openssl_date(fields.get("notafter", "")) + not_before = _parse_openssl_date(fields.get("notbefore", "")) + + code, text = run([OPENSSL, "x509", "-noout", "-text"], pem) + is_ca = "CA:TRUE" in text if code == 0 else None + key_bits = None + key_match = re.search(r"Public-Key:\s*\((\d+) bit\)", text or "") + if key_match: + key_bits = int(key_match.group(1)) + return { + "subject": subject, + "issuer": issuer, + "organization": subject.get("O") or subject.get("CN"), + "self_signed": bool(subject) and subject == issuer, + "is_ca": is_ca, + "key_bits": key_bits, + "serial": fields.get("serial"), + "not_before": (time.strftime("%Y-%m-%d", time.gmtime(not_before)) + if not_before else None), + "not_after": (time.strftime("%Y-%m-%d", time.gmtime(not_after)) + if not_after else None), + "expired": bool(not_after and not_after < now_ts), + "days_left": (round((not_after - now_ts) / 86400) if not_after else None), + } + + +def _split_pems(text: str) -> list[str]: + return [block + "-----END CERTIFICATE-----\n" + for block in text.split("-----END CERTIFICATE-----") + if "-----BEGIN CERTIFICATE-----" in block] + + +def collect_trust_roots(run: Runner = run_command, *, + now_ts: Optional[float] = None) -> tuple[list[dict], dict]: + now = time.time() if now_ts is None else now_ts + roots: list[dict] = [] + statuses: list[str] = [] + for domain, args in (("admin", [SECURITY, "dump-trust-settings", "-d"]), + ("user", [SECURITY, "dump-trust-settings"])): + code, out = run(args, None) + if code != 0 and not out.strip(): + statuses.append(f"{domain}:unavailable") + continue + statuses.append(f"{domain}:ok") + roots.extend(parse_trust_settings(out, domain)) + for root in roots: + code, pem_out = run([SECURITY, "find-certificate", "-a", "-c", root["name"], "-p"], None) + pems = _split_pems(pem_out) if code == 0 else [] + root["matches"] = len(pems) + root["certificate"] = describe_certificate(pems[0], run, now) if pems else None + return roots, {"source": "trust_roots", "status": "|".join(statuses) or "unavailable", + "count": len(roots)} + + +# --------------------------------------------------------------------------- +# Correlation — the verdict neither source produces alone +# --------------------------------------------------------------------------- + +_TOKEN_RE = re.compile(r"[^a-z0-9]+") + + +def _vendor_token(value: Optional[str]) -> str: + return _TOKEN_RE.sub("", (value or "").lower()) + + +def attribute_roots(roots: list[dict], receipts: list[dict]) -> None: + """Link each trusted root to the package family that installed it. + + Matching is on the vendor token shared between the certificate's + organization and the package id -- both vendor-chosen strings, so a match is + evidence rather than a heuristic guess. No match yields `unattributed`, + which is a statement about attribution, not about the root's legitimacy. + """ + by_vendor: dict[str, list[dict]] = {} + for receipt in receipts: + by_vendor.setdefault(receipt["vendor"], []).append(receipt) + for root in roots: + certificate = root.get("certificate") or {} + token = _vendor_token(certificate.get("organization")) or _vendor_token(root["name"]) + owners = [pkgs for vendor, pkgs in sorted(by_vendor.items()) + if token and token in _vendor_token(vendor)] + packages = [pkg for group in owners for pkg in group] + root["owner_packages"] = [pkg["pkgid"] for pkg in packages] + if not packages: + root["attribution"] = "unattributed" + root["verdict"] = "unattributed" + else: + root["attribution"] = "attributed" + living = [pkg for pkg in packages if pkg["state"] in ("present", "partial")] + root["owner_installed_on"] = max( + (pkg["installed_on"] for pkg in packages if pkg["installed_on"]), default=None) + root["verdict"] = "attributed" if living else "orphaned" + root["evidence"] = "preview" + + +def _root_rank(root: dict) -> tuple: + certificate = root.get("certificate") or {} + return ( + 0 if root["verdict"] == "orphaned" else 1 if root["verdict"] == "unattributed" else 2, + 0 if root.get("unconditional") else 1, + 0 if certificate.get("is_ca") else 1, + -(certificate.get("days_left") or 0), + root["name"], + ) + + +def build_moraine(run: Runner = run_command, *, now_ts: Optional[float] = None, + sample_cap: int = FILE_SAMPLE_CAP) -> dict: + now = time.time() if now_ts is None else now_ts + receipts, receipt_status = collect_receipts(run, sample_cap=sample_cap) + roots, root_status = collect_trust_roots(run, now_ts=now) + attribute_roots(roots, receipts) + roots.sort(key=_root_rank) + vendors = summarize_vendors(receipts) + non_apple = [v for v in vendors if not v["apple"]] + return { + "contract": ("read-only; installer receipts and trust-store entries only; " + "no file content read, nothing written, nothing deleted"), + "evidence": "preview", + "prior_art": ("AppCleaner covers the file-sweep half of this and covers it well; " + "it is target-driven and cannot open the trust store, which is the " + "half reported here"), + "sources": [receipt_status, root_status], + "receipts": { + "total": len(receipts), + "non_apple": sum(1 for r in receipts if not r["apple"]), + "vanished": sum(1 for r in receipts if r["state"] == "vanished"), + "sampled": sum(1 for r in receipts if r["sampled"]), + "vendors": vendors, + }, + "trust_roots": { + "total": len(roots), + "orphaned": sum(1 for r in roots if r["verdict"] == "orphaned"), + "unattributed": sum(1 for r in roots if r["verdict"] == "unattributed"), + "unconditional": sum(1 for r in roots if r.get("unconditional")), + "items": roots, + }, + "removed_vendors": [v["vendor"] for v in non_apple if v["fully_removed"]], + } + + +# --------------------------------------------------------------------------- +# Text report +# --------------------------------------------------------------------------- + +_VERDICT_LABEL = { + "orphaned": "orphaned — installing package has no payload left", + "unattributed": "unattributed — no installer receipt claims it", + "attributed": "attributed — owner package still installed", +} + + +def render_report(report: dict, limit: int) -> str: + lines = ["Modore moraine — what stayed after the installer left " + "(read-only · receipts + trust store · preview evidence)"] + lines.append("sources: " + " · ".join( + f"{s['source']} {s['status']} ({s['count']})" for s in report["sources"])) + receipts = report["receipts"] + lines.append(f"receipts {receipts['total']} — non-Apple {receipts['non_apple']}" + f" · payload gone {receipts['vanished']}" + + (f" · file-sampled {receipts['sampled']}" if receipts["sampled"] else "")) + roots = report["trust_roots"] + lines.append(f"trusted roots {roots['total']} — orphaned {roots['orphaned']}" + f" · unattributed {roots['unattributed']}" + f" · unconditional {roots['unconditional']}") + + if roots["items"]: + lines.append("") + lines.append("trusted roots (worst first)") + for root in roots["items"][:limit]: + certificate = root.get("certificate") or {} + marks = [_VERDICT_LABEL.get(root["verdict"], root["verdict"])] + if root.get("unconditional"): + marks.append("all policies") + if certificate.get("expired"): + marks.append("EXPIRED") + lines.append(f" {root['name']} [{root['domain']}] — " + " · ".join(marks)) + if certificate: + shape = [] + if certificate.get("self_signed"): + shape.append("self-signed") + if certificate.get("is_ca"): + shape.append("CA:TRUE") + if certificate.get("key_bits"): + shape.append(f"{certificate['key_bits']}-bit") + window = f"{certificate.get('not_before') or '?'} → {certificate.get('not_after') or '?'}" + days = certificate.get("days_left") + lines.append(f" {' · '.join(shape) or 'certificate'} | {window}" + + (f" ({days}d left)" if days is not None and days > 0 else "")) + if root.get("owner_packages"): + shown = ", ".join(root["owner_packages"][:3]) + more = len(root["owner_packages"]) - 3 + lines.append(f" installed by: {shown}" + (f" (+{more})" if more > 0 else "") + + (f", {root['owner_installed_on']}" if root.get("owner_installed_on") else "")) + if len(roots["items"]) > limit: + lines.append(f" … {len(roots['items']) - limit} more (--limit or --json)") + + removed = report["removed_vendors"] + if removed: + lines.append("") + lines.append(f"vendors whose packages are all gone from disk ({len(removed)}): " + + ", ".join(removed[:12]) + ("…" if len(removed) > 12 else "")) + lines.append("") + lines.append("Nothing here is deleted or scheduled for deletion. Removing a trust root is " + "an admin act and stays a human decision.") + return "\n".join(lines) + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Audit what stayed behind after software was removed.") + parser.add_argument("--json", action="store_true", help="print the full report as JSON") + parser.add_argument("--limit", type=int, default=10, + help="trusted roots to show in the text report") + parser.add_argument("--sample-cap", type=int, default=FILE_SAMPLE_CAP, + help=argparse.SUPPRESS) + args = parser.parse_args(argv) + if args.limit <= 0 or args.sample_cap <= 0: + print("moraine: --limit and --sample-cap must be positive", file=sys.stderr) + return 2 + if sys.platform != "darwin": + print("moraine: macOS only (reads pkgutil receipts and the macOS trust store)", + file=sys.stderr) + return 2 + report = build_moraine(sample_cap=args.sample_cap) + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + print(render_report(report, args.limit)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_smoke.py b/scripts/release_smoke.py index dd16310..1625737 100644 --- a/scripts/release_smoke.py +++ b/scripts/release_smoke.py @@ -355,6 +355,7 @@ def verify_tag_with_signer( "scripts/schedule.sh", "scripts/scree.py", "scripts/friction.py", + "scripts/moraine.py", "scripts/mcp_server.py", "scripts/idle_cpu.sh", "scripts/network_watch.sh", diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index e07f725..ed201fe 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -23,9 +23,9 @@ def _payload(result: dict) -> dict: # 읽기 전용 경계 — 이 표면의 존재 이유 # --------------------------------------------------------------------------- -def test_only_three_read_only_tools_are_exposed(): - assert sorted(mcp_server.HANDLERS) == ["friction_scan", "scree_report", - "system_scan_summary"] +def test_only_the_declared_read_only_tools_are_exposed(): + assert sorted(mcp_server.HANDLERS) == ["friction_scan", "moraine_report", + "scree_report", "system_scan_summary"] def test_every_tool_is_annotated_read_only_and_non_destructive(): @@ -75,7 +75,7 @@ def fake_run(argv, **kwargs): assert spawned, "expected the judgment scripts to be invoked" for argv in spawned: script = Path(argv[3]).name - assert script in ("scree.py", "friction.py"), argv + assert script in ("scree.py", "friction.py", "moraine.py"), argv joined = " ".join(argv) for forbidden in ("cleanup", "scanner", "storage_watch", "schedule", "preserve", "--raw"): @@ -116,9 +116,10 @@ def test_a_tool_rejected_by_the_contract_is_unreachable_not_merely_unlisted(monk assert response["error"]["code"] == mcp_server.METHOD_NOT_FOUND -def test_the_two_judgment_scripts_are_the_declared_targets(): +def test_the_judgment_scripts_are_the_declared_targets(): assert mcp_server.SCREE.name == "scree.py" assert mcp_server.FRICTION.name == "friction.py" + assert mcp_server.MORAINE.name == "moraine.py" def test_scan_summary_states_that_it_cannot_start_a_scan(tmp_path, monkeypatch): @@ -172,13 +173,29 @@ def test_scan_summary_states_that_it_cannot_start_a_scan(tmp_path, monkeypatch): } +_MORAINE_FIXTURE = { + "contract": "read-only", + "evidence": "preview", + "prior_art": "AppCleaner covers the file-sweep half", + "sources": [{"source": "receipts", "status": "ok", "count": 2}], + "receipts": {"total": 2, "non_apple": 1, "vanished": 1, "sampled": 0, + "vendors": [{"vendor": "com.innorix", "apple": False, "fully_removed": True}, + {"vendor": "com.apple", "apple": True, "fully_removed": False}]}, + "trust_roots": {"total": 2, "orphaned": 1, "unattributed": 1, "unconditional": 1, + "items": [{"name": "INNORIX.CA", "verdict": "orphaned"}, + {"name": "AirFRONT", "verdict": "unattributed"}]}, + "removed_vendors": ["com.innorix"], +} + + @pytest.fixture def stub_scripts(monkeypatch): calls = [] def fake(script, arguments, timeout): calls.append((script.name, arguments)) - return _SCREE_FIXTURE if script.name == "scree.py" else _FRICTION_FIXTURE + return {"scree.py": _SCREE_FIXTURE, "friction.py": _FRICTION_FIXTURE, + "moraine.py": _MORAINE_FIXTURE}[script.name] monkeypatch.setattr(mcp_server, "_run_json", fake) return calls @@ -237,6 +254,22 @@ def test_truncation_is_always_accounted_for(stub_scripts): assert payload["omitted"] == 1 +def test_moraine_report_forwards_the_correlated_verdict(stub_scripts): + payload = _payload(_call("moraine_report", {"section": "all", "limit": 10})) + assert payload["summary"]["trust_roots_orphaned"] == 1 + assert payload["summary"]["removed_vendors"] == ["com.innorix"] + assert payload["trust_roots"]["items"][0]["name"] == "INNORIX.CA" + # Apple 패키지는 벤더 롤업에서 빠진다 — 사람이 읽을 대상이 아니다. + assert [v["vendor"] for v in payload["receipts"]["non_apple_vendors"]] == ["com.innorix"] + assert stub_scripts == [("moraine.py", ["--json"])] + + +def test_moraine_summary_section_is_counts_only(stub_scripts): + payload = _payload(_call("moraine_report", {"section": "summary"})) + assert "trust_roots" not in payload and "receipts" not in payload + assert payload["trust_roots_unconditional"] == 1 + + # --------------------------------------------------------------------------- # 오염 방지 — 결과는 전부 데이터다 # --------------------------------------------------------------------------- @@ -246,7 +279,7 @@ def test_every_tool_result_is_fenced_as_untrusted(stub_scripts, tmp_path, monkey monkeypatch.setattr(mcp_server, "PROJECT_ROOT", tmp_path) monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) for name, arguments in (("scree_report", {}), ("friction_scan", {}), - ("system_scan_summary", {})): + ("system_scan_summary", {}), ("moraine_report", {})): text = _call(name, arguments)["content"][0]["text"] assert text.startswith(mcp_server.UNTRUSTED_OPEN), name assert text.rstrip().endswith(mcp_server.UNTRUSTED_CLOSE), name @@ -272,6 +305,8 @@ def test_server_instructions_state_the_read_only_contract(): ("scree_report", {"section": "everything"}), ("scree_report", {"limit": 0}), ("system_scan_summary", {"limit": 101}), + ("moraine_report", {"section": "everything"}), + ("moraine_report", {"limit": 201}), ]) def test_bad_arguments_are_tool_errors_not_crashes(tool, arguments, stub_scripts): result = _call(tool, arguments) @@ -294,7 +329,7 @@ def test_initialize_echoes_a_supported_version_and_falls_back_otherwise(): def test_tools_list_declares_closed_input_schemas(): tools = mcp_server.handle_request("tools/list", {})["tools"] assert [t["name"] for t in tools] == ["scree_report", "friction_scan", - "system_scan_summary"] + "system_scan_summary", "moraine_report"] for tool in tools: assert tool["inputSchema"]["additionalProperties"] is False assert tool["description"] and tool["title"] @@ -343,7 +378,7 @@ def test_cli_tools_dump_is_the_registered_surface(capsys): assert mcp_server.main(["--tools"]) == 0 dumped = json.loads(capsys.readouterr().out) assert [t["name"] for t in dumped["exposed"]] == ["scree_report", "friction_scan", - "system_scan_summary"] + "system_scan_summary", "moraine_report"] assert dumped["rejected"] == [] diff --git a/tests/test_moraine.py b/tests/test_moraine.py new file mode 100644 index 0000000..153b7f5 --- /dev/null +++ b/tests/test_moraine.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""moraine 계약 테스트: 영수증·트러스트 스토어 판독, 상관 판정, 읽기 전용 경계. + +명령 실행기를 주입해 실제 시스템 상태에 의존하지 않는다. 픽스처는 이 맥에서 +실측한 INNORIX 사례(2026-08-19)의 형태를 그대로 옮긴 것이다. +""" +import json +import plistlib +import subprocess +import sys +from pathlib import Path + +import pytest + +import moraine + + +# --------------------------------------------------------------------------- +# 픽스처: 실측 출력 형태를 재현하는 가짜 실행기 +# --------------------------------------------------------------------------- + +INNORIX_PKGS = ["com.innorix.innorixexagent.ca.pkg", + "com.innorix.innorixexagent.innorixes.pkg"] +APPLE_PKG = "com.apple.pkg.CLTools_Executables" +LIVE_PKG = "com.example.stillhere.pkg" + +ADMIN_DUMP = """Number of trusted certs = 1 +Cert 0: INNORIX.CA + Number of trust settings : 0 +""" + +USER_DUMP = """Number of trusted certs = 1 +Cert 0: AirFRONT + Number of trust settings : 2 + Trust Setting 0: + Policy OID : EAP + Allowed Error : CSSMERR_TP_CERT_EXPIRED + Result Type : kSecTrustSettingsResultTrustAsRoot + Trust Setting 1: + Policy OID : Apple X509 Basic + Allowed Error : CSSMERR_TP_CERT_EXPIRED + Result Type : kSecTrustSettingsResultTrustAsRoot +""" + +PEM = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n" + +# LibreSSL(시스템 openssl)의 슬래시 구분 DN 형식. +INNORIX_FIELDS = ("subject= /C=KR/O=INNORIX/CN=INNORIX.CA\n" + "issuer= /C=KR/O=INNORIX/CN=INNORIX.CA\n" + "notBefore=Jul 10 09:06:37 2018 GMT\n" + "notAfter=Jul 7 09:06:37 2028 GMT\n" + "serial=01\n") +AIRFRONT_FIELDS = ("subject= /C=KR/O=AirFRONT/CN=AirFRONT\n" + "issuer= /C=KR/O=AirFRONT/CN=AirFRONT\n" + "notBefore=Jun 3 00:00:00 2020 GMT\n" + "notAfter=May 10 00:00:00 2120 GMT\n" + "serial=02\n") +CERT_TEXT = " Public-Key: (2048 bit)\n CA:TRUE, pathlen:0\n" + +# 2026-08-19 기준 고정 시각. 만료 판정이 실행 날짜에 흔들리지 않게 한다. +NOW = 1786000000.0 + + +def _plist(pkgid, location, install_time): + return plistlib.dumps({ + "pkgid": pkgid, "pkg-version": "1.0", "volume": "/", + "install-location": location, "install-time": install_time, + }).decode("utf-8") + + +class FakeSystem: + """주입되는 명령 실행기. 호출 이력을 남겨 읽기 전용 경계를 검증한다.""" + + def __init__(self, *, files_on_disk=(), pkgs=None, admin=ADMIN_DUMP, user=USER_DUMP): + self.files_on_disk = set(files_on_disk) + self.pkgs = INNORIX_PKGS + [APPLE_PKG] if pkgs is None else pkgs + self.admin = admin + self.user = user + self.calls = [] + + def __call__(self, args, stdin_text=None): + self.calls.append(list(args)) + if args[:2] == [moraine.PKGUTIL, "--pkgs"]: + return 0, "\n".join(self.pkgs) + "\n" + if args[1:2] == ["--pkg-info-plist"]: + pkgid = args[2] + location = "tmp/INNORIX-EX/register" if "innorix" in pkgid else "usr" + return 0, _plist(pkgid, location, 1777610110) + if args[1:2] == ["--files"]: + pkgid = args[2] + return 0, "payload-a\npayload-b\n" if pkgid != LIVE_PKG else "live-a\n" + if args[1:2] == ["dump-trust-settings"]: + return (0, self.admin) if "-d" in args else (0, self.user) + if args[1:2] == ["find-certificate"]: + return 0, PEM + if args[1:2] == ["x509"]: + if "-text" in args: + return 0, CERT_TEXT + return 0, AIRFRONT_FIELDS if self._airfront_turn() else INNORIX_FIELDS + return 127, "" + + def _airfront_turn(self): + # find-certificate 는 이름으로 조회되므로 직전 호출의 이름으로 구분한다. + for call in reversed(self.calls): + if call[1:2] == ["find-certificate"]: + return "AirFRONT" in call + return False + + +@pytest.fixture +def system(monkeypatch, tmp_path): + fake = FakeSystem() + # 파일 존재 판정만 실제 파일시스템을 쓴다. 아무것도 만들지 않으면 payload가 + # 전부 사라진 상태 = INNORIX 사례가 재현된다. + monkeypatch.setattr(moraine.Path, "exists", lambda self: str(self) in fake.files_on_disk) + return fake + + +# --------------------------------------------------------------------------- +# 트러스트 설정 판독 — 0개 설정이 "무제한 신뢰"라는 점 +# --------------------------------------------------------------------------- + +def test_zero_trust_settings_is_unconditional_not_unconfigured(): + """macOS 규약상 사용 제약이 빈 배열이면 '모든 정책에 대해 루트로 신뢰'다. + '설정 안 됨'으로 읽으면 정확히 반대 결론이 나오므로 명시적으로 고정한다.""" + roots = moraine.parse_trust_settings(ADMIN_DUMP, "admin") + assert len(roots) == 1 + assert roots[0]["name"] == "INNORIX.CA" + assert roots[0]["settings_count"] == 0 + assert roots[0]["unconditional"] is True + assert roots[0]["trusted_as_root"] is True + + +def test_explicit_policy_settings_are_not_unconditional(): + roots = moraine.parse_trust_settings(USER_DUMP, "user") + assert roots[0]["unconditional"] is False + assert roots[0]["trusted_as_root"] is True + assert roots[0]["policies"] == ["EAP", "Apple X509 Basic"] + + +def test_empty_dump_yields_no_roots(): + assert moraine.parse_trust_settings("Number of trusted certs = 0\n", "admin") == [] + + +# --------------------------------------------------------------------------- +# 인증서 판독 — 두 openssl 출력 형식 모두 +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("dn", [ + "subject= /C=KR/O=INNORIX/CN=INNORIX.CA", # LibreSSL (시스템 openssl) + "subject=C=KR, O=INNORIX, CN=INNORIX.CA", # OpenSSL 3 +]) +def test_dn_parsing_tolerates_both_openssl_formats(dn): + parsed = moraine.parse_dn(dn) + assert parsed["O"] == "INNORIX" + assert parsed["CN"] == "INNORIX.CA" + assert parsed["C"] == "KR" + + +def test_certificate_shape_is_read_from_openssl(system): + described = moraine.describe_certificate(PEM, system, NOW) + assert described["organization"] == "INNORIX" + assert described["self_signed"] is True + assert described["is_ca"] is True + assert described["key_bits"] == 2048 + assert described["not_before"] == "2018-07-10" + assert described["not_after"] == "2028-07-07" + assert described["expired"] is False + assert described["days_left"] > 0 + + +def test_expired_certificate_is_flagged(system): + far_future = NOW + 86400 * 365 * 20 + assert moraine.describe_certificate(PEM, system, far_future)["expired"] is True + + +# --------------------------------------------------------------------------- +# 영수증 판독 +# --------------------------------------------------------------------------- + +def test_receipt_with_no_surviving_payload_is_vanished(system): + receipts, status = moraine.collect_receipts(system) + assert status["status"] == "ok" + innorix = [r for r in receipts if r["vendor"] == "com.innorix"] + assert len(innorix) == 2 + assert all(r["state"] == "vanished" for r in innorix) + assert all(r["installed_on"] == "2026-05-01 13:35" for r in innorix) + + +def test_receipt_with_surviving_payload_is_present(system): + system.files_on_disk = {"/tmp/INNORIX-EX/register/payload-a", + "/tmp/INNORIX-EX/register/payload-b"} + receipts, _ = moraine.collect_receipts(system) + innorix = [r for r in receipts if r["vendor"] == "com.innorix"] + assert all(r["state"] == "present" for r in innorix) + + +def test_partial_payload_is_its_own_state(system): + system.files_on_disk = {"/tmp/INNORIX-EX/register/payload-a"} + receipts, _ = moraine.collect_receipts(system) + assert {r["state"] for r in receipts if r["vendor"] == "com.innorix"} == {"partial"} + + +def test_apple_packages_are_marked_and_sorted_last(system): + receipts, _ = moraine.collect_receipts(system) + apple = [r for r in receipts if r["pkgid"] == APPLE_PKG] + assert apple and apple[0]["apple"] is True + vendors = moraine.summarize_vendors(receipts) + assert vendors[-1]["vendor"] == "com.apple" + + +def test_vendor_namespace_comes_from_the_reverse_dns_id(): + assert moraine._vendor_of_pkgid("com.innorix.innorixexagent.ca.pkg") == "com.innorix" + assert moraine._vendor_of_pkgid("standalone") == "standalone" + + +def test_file_sampling_is_reported_never_silent(system): + receipts, _ = moraine.collect_receipts(system, sample_cap=1) + sampled = [r for r in receipts if r["sampled"]] + assert sampled, "2-file packages must be marked sampled at cap 1" + assert all(r["files_checked"] == 1 and r["files_total"] == 2 for r in sampled) + + +def test_unavailable_pkgutil_degrades_instead_of_raising(): + receipts, status = moraine.collect_receipts(lambda args, stdin=None: (127, "")) + assert receipts == [] + assert status["status"] == "unavailable" + + +# --------------------------------------------------------------------------- +# 상관 판정 — 어느 한쪽만으로는 못 내는 결론 +# --------------------------------------------------------------------------- + +def test_trusted_root_whose_installer_payload_is_gone_is_orphaned(system): + report = moraine.build_moraine(system, now_ts=NOW) + innorix = [r for r in report["trust_roots"]["items"] if r["name"] == "INNORIX.CA"][0] + assert innorix["verdict"] == "orphaned" + assert innorix["attribution"] == "attributed" + assert sorted(innorix["owner_packages"]) == sorted(INNORIX_PKGS) + assert innorix["unconditional"] is True + assert innorix["evidence"] == "preview" + + +def test_the_same_root_is_only_attributed_while_its_owner_survives(system): + system.files_on_disk = {"/tmp/INNORIX-EX/register/payload-a", + "/tmp/INNORIX-EX/register/payload-b"} + report = moraine.build_moraine(system, now_ts=NOW) + innorix = [r for r in report["trust_roots"]["items"] if r["name"] == "INNORIX.CA"][0] + assert innorix["verdict"] == "attributed" + assert report["trust_roots"]["orphaned"] == 0 + + +def test_a_root_with_no_receipt_is_unattributed_not_accused(system): + report = moraine.build_moraine(system, now_ts=NOW) + airfront = [r for r in report["trust_roots"]["items"] if r["name"] == "AirFRONT"][0] + assert airfront["verdict"] == "unattributed" + assert airfront["owner_packages"] == [] + # MDM 프로파일·기업 와이파이·수동 임포트는 영수증이 없는 게 정상이다. + assert "unattributed" in moraine._VERDICT_LABEL[airfront["verdict"]] + + +def test_worst_first_ordering_puts_orphaned_unconditional_roots_on_top(system): + report = moraine.build_moraine(system, now_ts=NOW) + assert [r["name"] for r in report["trust_roots"]["items"]] == ["INNORIX.CA", "AirFRONT"] + + +def test_fully_removed_vendors_are_named(system): + report = moraine.build_moraine(system, now_ts=NOW) + assert report["removed_vendors"] == ["com.innorix"] + + +def test_counts_agree_with_the_item_lists(system): + report = moraine.build_moraine(system, now_ts=NOW) + roots = report["trust_roots"] + assert roots["total"] == len(roots["items"]) + assert roots["orphaned"] == sum(1 for r in roots["items"] if r["verdict"] == "orphaned") + assert roots["unconditional"] == sum(1 for r in roots["items"] if r["unconditional"]) + + +# --------------------------------------------------------------------------- +# 읽기 전용 경계 +# --------------------------------------------------------------------------- + +def test_only_read_only_commands_are_ever_run(system): + moraine.build_moraine(system, now_ts=NOW) + allowed_first = {moraine.PKGUTIL, moraine.SECURITY, moraine.OPENSSL} + for call in system.calls: + assert call[0] in allowed_first, call + joined = " ".join(call) + for forbidden in ("delete", "remove", "forget", "add-trusted-cert", + "import", "unload", "rm ", "--forget"): + assert forbidden not in joined, call + + +def test_the_module_never_writes_or_deletes(): + source = Path(moraine.__file__).read_text(encoding="utf-8") + for forbidden in ("write_text", "open(", "unlink", "rmtree", "shutil", + "mkdir", "os.remove", "NamedTemporary"): + assert forbidden not in source, f"moraine must not use {forbidden}" + + +def test_report_says_plainly_that_nothing_was_deleted(system): + text = moraine.render_report(moraine.build_moraine(system, now_ts=NOW), 10) + assert "Nothing here is deleted" in text + assert "human decision" in text + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def test_cli_rejects_nonpositive_limits(): + assert moraine.main(["--limit", "0"]) == 2 + + +@pytest.mark.skipif(sys.platform != "darwin", reason="moraine reads macOS-only sources") +def test_cli_json_runs_against_the_real_machine(): + proc = subprocess.run([sys.executable, "-I", "-B", str(Path(moraine.__file__)), "--json"], + capture_output=True, text=True, timeout=300) + assert proc.returncode == 0, proc.stderr + payload = json.loads(proc.stdout) + assert payload["evidence"] == "preview" + assert payload["receipts"]["total"] >= 0 + assert set(payload["trust_roots"]) >= {"total", "orphaned", "unattributed", "items"} diff --git a/tests/test_release_hardening.py b/tests/test_release_hardening.py index 9fad43f..d4210cf 100644 --- a/tests/test_release_hardening.py +++ b/tests/test_release_hardening.py @@ -1321,7 +1321,8 @@ def test_release_ships_frictions_and_the_mcp_surfaces_dependencies(project_root) release where the documented command fails at import or at first call — the exact failure mode the scree test above exists to prevent, one layer up.""" module = load_release_smoke(project_root) - for script in ("scripts/scree.py", "scripts/friction.py", "scripts/mcp_server.py"): + for script in ("scripts/scree.py", "scripts/friction.py", "scripts/moraine.py", + "scripts/mcp_server.py"): assert script in module.MACOS_FILES diff --git a/tests/test_service_contracts.py b/tests/test_service_contracts.py index 51c484e..6afebe7 100644 --- a/tests/test_service_contracts.py +++ b/tests/test_service_contracts.py @@ -646,6 +646,7 @@ def test_bundled_app_runtime_includes_every_macos_script(project_root): # same way scree.py did. mcp_server.py is a stdio server that a separate # MCP client launches from a checkout; the app never spawns it. "scripts/friction.py", + "scripts/moraine.py", "scripts/mcp_server.py", } non_shell_expected = {