feat(aegis): 4 detection surfaces + 4 opt-in tiers — the doit round (beacon, argv-obfuscation, Windows evasions, Sysmon, intel, rootwatch, setup, menubar) - #4
Merged
Conversation
Outbound can't be baseline-diffed (browser churn), but recurrence keys on the opposite invariant: the same (binary, remote ip:port) pair persisting across scans is C2-beacon residue. Each scan's outbound row set (with trust captured at observation time) is stored via the observation store; the pure _beacon_recurrence analysis fires HIGH when a pair live this scan was seen in >=3 distinct scans spanning >=45 min, from a non-browser binary outside every trusted prefix whose signature is suspicious or which runs from a user-writable path. Stable beacon:path:ip:port fingerprint = one incident, occurrence count climbs, no re-alert storm. Live per-scan scoring is unchanged; a probe non-answer stores nothing. 10 new sandboxed tests (fail-before verified); full suite 681 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the rung above Run keys/schtasks/Winlogon: - COM hijacking (T1546.015): HKCU CLSID InprocServer32/LocalServer32 default values, baseline-diffed; a new/changed server resolving into a user-writable path is HIGH (a Program Files target is silent app churn). - IFEO (T1546.012/.008): HKLM Image File Execution Options Debugger and SilentProcessExit MonitorProcess values; a new Debugger on an accessibility binary (sethc/utilman/osk/magnify/narrator/displayswitch) is CRITICAL, any other new/changed Debugger/MonitorProcess is HIGH. - AppInit_DLLs (T1546.010): a non-empty value appearing or changing is HIGH. - Sysmon harvest: registered ONLY where the Operational channel exists (absent = product not installed = sensor absent, never DEGRADED). Narrow: EID 1 ProcessCreate scored through _argv_signals + is_risky_location (only what scores surfaces), EID 6 unsigned driver load HIGH, EID 25 process tampering HIGH. All parsing/scoring is pure and tested cross-platform with text/dict fixtures; the winreg/PowerShell probes are thin Windows-gated shells. Every probe follows the non-answer rule (failed read -> None/DEGRADED, never a false-empty baseline). New surfaces register into SURFACES (Windows) and the Sysmon sensor into gather_all (Windows) via one-line hooks. README layer table updated. 42 new tests in tests/test_win_evasion.py; full suite green (713 tests, 4 skips). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/win-evasion-sensors)
…een checkmark
menubar/aegis-status.30s.py: a standalone, stdlib-only, strictly READ-ONLY
menu-bar viewer of Aegis state (xbar and SwiftBar compatible), refreshed
every 30s.
Title states, most important first:
💀 heartbeat missing-though-installed or stale past the cmd_watchdog
tolerance (3h) — the monitor itself is dead, the one state a dead
monitor cannot report and the reason the plugin exists
⚠️ N N active incidents (full count in the title, worst-first top-5 in
the dropdown, worst severity colors it)
🛡️ heartbeat fresh, no open incidents
⚪ ~/.aegis absent/empty — calm "not installed", never an alarm
Dropdown: last scan (relative), open incidents (id/severity/title), degraded
sensors, heartbeat age, then actions — open latest.md, `incidents` and `scan`
in Terminal via xbar shell= params against the install.sh runtime copy.
Doctrine, held structurally rather than by promise:
* read-only: aegis.db is opened with the same `mode=ro&immutable=1` URI
idiom aegis.py uses on other processes' DBs (cannot lock, journal, or
create); the only other read is byte-capped open(..., "r"); no write-mode
open, no makedirs, no delete, no networking import anywhere in the file
* standalone: never imports aegis.py — the three tiny readers (heartbeat,
incidents, sensor health) are re-implemented against the documented shapes
* never crashes: a crashed plugin renders NOTHING in the menu bar, so every
reader degrades its own line and main() cannot exit non-zero; hostile db
text is '|'-sanitized so an incident title cannot forge xbar params into
its own menu line
tests/test_menubar.py (19 tests, fail-before captured: all 19 failed with the
plugin absent) runs the plugin as a subprocess against a sandboxed
AEGIS_STATE_DIR and wraps EVERY invocation in a full before/after inventory
(paths + sizes + mtime_ns) — any write to the state dir fails the suite. Also
pinned: a real-sqlite HIGH incident renders ⚠️ 1 with its title; stale/missing
heartbeat is 💀 and outranks open incidents; absent AND empty dirs are "not
installed" (exit 0, dir never created); corrupt db + corrupt heartbeat still
render a title; a 4MB heartbeat is read bounded; the import ban is enforced
via ast, not prose.
README: "Menu-bar status" section with one-line SwiftBar and xbar installs
and the read-only guarantee. aegis.py itself is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A same-uid attacker can kill Aegis AND the user-level watchdog agent in one sweep; the notary only makes that evident later. `aegis.py rootwatch install|status|uninstall` adds the one privileged component in the tool, held to the bastion doctrine — opt-in, single-purpose, tiny: - The privileged part is a SEPARATE generated script (59 lines, stdlib json/os/subprocess/sys/time only — the smallness is the security argument, stated in its header), installed root-owned 0755 at /usr/local/libexec/aegis-rootwatch.py with the installing user's heartbeat path, uid, and the watchdog's HEARTBEAT_STALE_SECS tolerance baked in at generation time. It reads the beat; fresh => exit 0 silent; stale/missing => append a root-owned alert line (/Library/Application Support/Aegis/rootwatch.log, /var/log/aegis on Linux), notify the user's session (launchctl asuser + osascript / notify-send + wall), and syslog via logger. It NEVER writes into the user's ~/.aegis (root-owned files there would break Aegis's atomic-replace state writes). - Schedule: root LaunchDaemon com.aegis.rootwatch (StartInterval 600) on macOS; a SYSTEM systemd service+timer (OnUnitActiveSec=600s, Persistent) on Linux. The daemon runs the hardcoded /usr/bin/python3, never a user venv — a root job on a user-writable interpreter would BE the escalation. - Aegis never self-elevates: `rootwatch install` without root performs ZERO mutation and prints exactly one pasteable sudo line. With root it writes script + plist/units atomically (tmp + fsync + replace + chown 0:0), bootstraps, and appends to the invoking user's actions.jsonl only if that file already exists (never creating a root-owned file in user state). `status` reports installed/registered/last-fired without root; `uninstall` boots out and removes but keeps the alert log as evidence. - doctor gains one INFO line (never a problem — it is opt-in): absent => "the kill gap is open: run `aegis.py rootwatch install`". - Windows: not built; documented honestly (a SYSTEM task is future work). Tests-first (tests/test_rootwatch.py, 16 tests, all failing on unmodified HEAD): the generated plist passes plutil -lint in a sandbox with '&' in the path; Linux unit shape asserted; the GENERATED script is executed for real against a fake heartbeat dir with PATH-stubbed launchctl/osascript/ notify-send/wall (fresh silent, stale alerts both platform shapes, wiped state reads as dead, and the never-write-into-~/.aegis guarantee proven by before/after inventory); non-root install proven to mutate nothing and print exactly one sudo line; the <=60-line audit budget and stdlib-only import set pinned. Full suite: 687 tests OK (3 normal skips). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… idiom tables can't see Detect an interpreter invoked with an inline-code flag (bash/sh/zsh/dash/ksh -c, python -c, node -e/--eval, perl/ruby/osascript -e, powershell -enc/-Command) whose code argument carries an encoded payload: - Gate 1 (mandatory): interpreter + code-exec flag. Electron helpers, JWTs and cloud-CLI opaque argv are structurally out of scope — never entropy-scored. - Gate 2a: a >=100-char base64/base64url-alphabet run that really decodes at >=4.5 bits/char Shannon entropy (random b64 ~5.8, b64-of-text ~5.3, benign ceiling ~4.3). Alphabet purity enforced: '+/' mixed with '-_' decodes under no base64 flavor — that mix is exactly a UUID/hash PATH, the one benign class measured to clear the entropy floor on this machine's live table. - Gate 2b: in-process decode+execute composition (eval(Buffer.from(..,'base64')), exec(base64.b64decode(..)), atob/fromCharCode) — the argv twin of the supply-chain js-encoded-loader, sharing _PKG_JS_DECODE_RES. Severity: MEDIUM alone (below the notify floor, corroboration fodder); HIGH only when the same argv also fetches or feeds the blob into a recognized exec sink. powershell -enc defers to the existing powershell-encoded-command idiom (already HIGH) — one argv, one strongest finding, stable fingerprint. Measured on this Mac's live process table: 611 processes, 417 same-user, 13 gate-1 matches, 2 candidate blob runs (both paths, both rejected), 0 false positives. Full suite 671 -> 682, green; adversarial argv timing linear (<=73ms at 85KB). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…te-check` runtime-copy drift detection Two commands that fix activation and staleness: - `aegis.py setup` — guided, idempotent walkthrough of the opt-in tiers (monitor, canary, latch, decoys [POSIX-only, silent on Windows], guard, watchdog pairing, off-host heartbeat URL). One benefit-and-cost sentence each, default No; a yes ORCHESTRATES the existing cmd_* directly, never a second implementation. Already-enabled tiers are detected from the same state the status/scan paths read, shown as [enabled], and skipped. Refuses a non-tty caller before touching any state (UX refusal, stated as distinct from unlatch's out-of-band security gate, which setup never consumes). Ends with a one-screen posture summary. rootwatch/intel are probed from the module at runtime — no merge dependency on sibling branches. - `aegis.py update-check [--remote]` — the runtime copy at ~/.aegis/aegis.py silently stales behind the repo (the README warned; nothing detected it). sha256-compares the invoked file vs RUNTIME_SCRIPT: drift → exit 1 with the exact shell-quoted refresh command (preserving the recorded install mode so the paste never downgrades watch→scan); in sync / not installed → exit 0; unhashable → honest unknown, exit 1. `--remote` fetches the canonical GitHub raw aegis.py derived from `git remote get-url origin` (config-file fallback where git is off run()'s restricted PATH), states the exact URL, sends nothing about the machine, and lazy-imports urllib inside the flag branch only — the `vt` pattern; the scan path stays structurally offline. cmd_doctor now surfaces runtime-copy drift as a DEGRADED problem, because doctor is where rot surfaces. Tests-first (tests/test_setup_updatecheck.py, 18 tests, fail-before captured on unmodified HEAD: 16 errors + 2 failures): non-tty refusal with byte-and-mtime-identical sandbox; all-No walkthrough mutates nothing; yes-to-canary leaves exactly the artifacts cmd_canary leaves; second run reports [enabled] and re-runs nothing; heartbeat URL paste stored (and non-http paste refused); setup never reaches authorize_interactive; drift/in-sync/not-installed exit codes; refresh line preserves watch mode; doctor drift line; stubbed urlopen proves --remote fetches exactly the stated URL once, plain update-check makes zero network calls, and the urllib import is lazy. Full suite: 689 tests OK (3 skips normal; 671 on the unmodified base + 18 new). Verified live on the reference machine: real drift detected against the actual ~/.aegis copy with a pasteable quoted refresh line, and a real --remote fetch against the GitHub origin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…|status`) Generalizes glean's doctrine — a dated, offline intel corpus graded over work the scan already does — beyond Apple's XProtect corpus to every OS, using two public abuse.ch exports (MalwareBazaar recent SHA256s, ThreatFox recent IOCs). By-hand half (`intel update`, the only place urllib is even imported, exactly like vt): fetches both feeds with no key/account, normalizes to size-bounded artifacts under ~/.aegis/intel/ (atomic save_json, 0600/0700, fetched_at stamped). Hashes and ip:port pairs ONLY — the feeds' domains/URLs are never written. A failed or garbage fetch keeps the prior copy and says so; hostile feed shapes parse to nothing rather than raising. Scan half (offline, read-only, structurally network-free): when local intel exists, the persistence snapshot's program hashes plus any finding already carrying a sha256 (hot-dir drops, app bundles) are graded via a memoized local set lookup — an exact match is CRITICAL "Known-malware hash (community intel)" naming feed, family and first_seen. check_outbound grades each live remote ip:port against the C2 set before the signature-gated generic scorer — a listed endpoint is CRITICAL regardless of the binary's signature. No intel fetched ⇒ the "intel" sensor is absent, never degraded. `intel status` (plus one line in `status`) reports ages/counts and calls out >7-day staleness. Tests (11, fail-before captured on the unmodified base: AttributeError on every case): the structural mirror of the vt tests — a full sandboxed scan with urlopen/create_connection/getaddrinfo booby-trapped to explode completes and flags a planted matching hash CRITICAL; both feed formats parse from fixtures through a stubbed transport; garbage never raises and never clobbers prior data; non-matching and no-intel scans stay silent (sensor absent from health); a known-C2 outbound row scores CRITICAL; stale feeds are reported. Full suite: 682 tests OK (3 platform skips). Both live feeds verified end-to-end into a scratch state dir, then removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # aegis.py
…indows quoting Both Windows CI jobs failed identically: the drift-refresh assertions built their own expected substring from a bare _SELF_PATH, but _refresh_line() quotes the path on Windows (the reference repo path has spaces and '&'), so the unquoted expectation never matched the quoted output. Use _refresh_line() itself as the oracle instead of re-deriving its quoting rule in the test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five detectors shipped with no positive control: outbound beacon
recurrence, the obfuscated-inline-payload argv scorer, community-intel
grading, the Windows COM/IFEO/AppInit diffs, and Sysmon event scoring.
That is the state the latch detector was in when it answered "unknown"
forever — reachable-looking, permanently silent, and nothing failing.
New lanes, each asserting BOTH poles:
beacon-recurrence a broken-signature row and a risky-path row that
recur across 3 scans / 69 min score HIGH; a browser
NAME, a 2-scan history, a 3-scan burst inside 9 min,
a signed binary in an ordinary path, and a pair no
longer live all stay silent.
argv-obfuscation interpreter + inline-code flag + a long decodable
high-entropy blob scores both obfuscated-inline-
payload and argv-encoded-loader, and a fetching argv
escalates to HIGH; print(1+1), a 160-char zero-
entropy run, English prose, and the SAME blob with
no code-execution flag (gate 1) stay silent. The
stimulus is base64 of an inert nonce-tagged marker
built at run time: no payload is committed, and the
nonce is still never persisted.
intel-match a hash and an ip:port on synthetic in-memory sets
grade CRITICAL through check_intel (both the
persistence-record and prior-finding paths) and
_intel_net_finding; a miss and empty sets stay
silent. _intel_sets is redirected for the lane's
duration, so no ~/.aegis/intel file is read and no
request is made.
win-evasion a COM server into a user-writable path is HIGH, a
sethc.exe IFEO Debugger is CRITICAL, AppInit_DLLs
being set is HIGH; a Program Files target, an
ordinary IFEO target (HIGH, not CRITICAL), and
already-adopted entries stay silent.
sysmon-scoring EID 1 with a hostile CommandLine is HIGH and with a
risky-path image is MEDIUM, EID 6 unsigned driver is
HIGH; a validly-signed driver, a clean process in a
trusted path, and a stray event ID stay silent, and
one event re-read in overlapping windows is one
finding.
The two Windows lanes swap IS_WIN and the prefix tables for their own
duration — unconditionally, so one control means the same thing on every
host — and restore them in `finally`; the live tables keep their existing
`risky-location` lane. Gating these to Windows would leave the author's
macOS machine reporting coverage it had never once exercised, which is
precisely the asserted-rather-than-demonstrated state this tier exists to
expose.
13 tests pin the LANES, not the detectors (those have their own suites).
The load-bearing one swaps each underlying detector for a dead stub AND
for a hardwired-yes stub and requires the lane to fail against both: a
control that only fed hostile input survives the hardwired-yes stub,
which is the failure mode the both-poles rule exists to prevent. The rest
pin that these lanes open no socket, write nothing but assay.json, leave
the intel memo and the real feed files untouched, pass with IS_WIN either
way while leaking no platform global, and build the argv blob from the
nonce.
Verified: `aegis.py assay` reports 19/19 controls proven (all five new
lanes PASS); full suite 812 tests OK, 4 platform skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ever rendered BATTLE-LOG gains the /doit build entry (4 detection surfaces, 4 opt-in tiers, 7 parallel branches), including the two defects found while building rather than shipped broken, and the CI failure that was a test's fault rather than the product's. The benign-note fix that rode along in abb728d gets its regression pin here: _benign_note_for does an exact lookup on the finding CATEGORY, so three notes keyed on the SURFACE id ("browserext", "ide_ext", "wallet") rendered for nobody — on browser extensions, editor extensions and wallet integrity, three of the most false-positive-prone surfaces in the tool. Pinned per-category rather than by scraping finding() call sites, because those categories are also emitted from multi-line calls with a variable severity that no source regex reads reliably. README's suite count was three releases stale (651 -> 812). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
install.sh has always written ProcessType=Background, LowPriorityIO, Nice=10 and ThrottleInterval=30. The Python port dropped all four, while the README says the two "do the same thing" — so the cross-platform path, which is the one `update-check` tells you to run, produced a monitor that scans un-niced at normal IO priority for about a minute at a time, and in watch mode had no bound on KeepAlive respawn: a crash-looping watch relaunches roughly once a second instead of every 30. Found by diffing this machine's deployed plist before and after refreshing its own agent — the only place the two installers' output actually meets. Pinned by parity rather than by a hardcoded list, so whichever installer gains a resource key next, the other has to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Seven parallel-built layers, each tests-first with captured fail-before evidence, merged and verified together: 798 tests green (from 671), plus a live end-to-end scan on a real machine (59s cold / 19s warm, only known true positives, one aggregated notification).
New detection surfaces
InprocServer32/LocalServer32→ HIGH on user-writable targets), IFEODebugger/SilentProcessExit(CRITICAL on accessibility binaries: sethc/utilman/osk/magnify/narrator/displayswitch), AppInit_DLLs (HIGH). Baseline-diffed, first-sight adoption, absent off-Windows.Microsoft-Windows-Sysmon/Operationalchannel exists: EID 1 scored through the existing argv machinery, EID 6 unsigned driver → HIGH, EID 25 tampering → HIGH. Channel absent = sensor absent, never degraded.New opt-in tiers
intel update|status— community IOC layer (MalwareBazaar + ThreatFox, no key). By-hand fetch only, lazy urllib exactly likevt; the scan grades already-computed hashes and outbound endpoints against the local sets → CRITICAL with feed/family/first_seen. A structural test runs a full scan with urlopen, create_connection, and getaddrinfo all replaced with raisers — the local-only guarantee stays literal.rootwatch install|status|uninstall— closes the same-uid kill gap. The only thing Aegis ever runs as root is a generated 59-line script (line count and import set pinned by tests) that reads one heartbeat file and alerts through channels a same-uid attacker cannot silence. Root job runs/usr/bin/python3+ a root-owned script, never user-writable code; non-root invocation mutates nothing and prints the single sudo paste.setup— guided walkthrough that activates the dormant opt-in tiers by orchestrating the existingcmd_*functions (proven zero-mutation on all-No, idempotent on re-run).update-check [--remote]— detects runtime-copy drift (the silent-staling failure the README warned about), plus a doctor line.menubar/aegis-status.30s.py) — xbar/SwiftBar, stdlib-only, structurally read-only (before/after inventory pinned across all 19 tests). 🛡️ healthy /Honest limits carried forward
net-beacon).🤖 Generated with Claude Code