Skip to content

feat(aegis): 4 detection surfaces + 4 opt-in tiers — the doit round (beacon, argv-obfuscation, Windows evasions, Sysmon, intel, rootwatch, setup, menubar) - #4

Merged
opencdlee-dotcom merged 18 commits into
mainfrom
feat/doit-integration
Aug 11, 2026
Merged

feat(aegis): 4 detection surfaces + 4 opt-in tiers — the doit round (beacon, argv-obfuscation, Windows evasions, Sysmon, intel, rootwatch, setup, menubar)#4
opencdlee-dotcom merged 18 commits into
mainfrom
feat/doit-integration

Conversation

@opencdlee-dotcom

Copy link
Copy Markdown
Owner

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

  • Windows persistence-evasion rung — COM hijacking (HKCU InprocServer32/LocalServer32 → HIGH on user-writable targets), IFEO Debugger/SilentProcessExit (CRITICAL on accessibility binaries: sethc/utilman/osk/magnify/narrator/displayswitch), AppInit_DLLs (HIGH). Baseline-diffed, first-sight adoption, absent off-Windows.
  • Sysmon harvest — when the Microsoft-Windows-Sysmon/Operational channel exists: EID 1 scored through the existing argv machinery, EID 6 unsigned driver → HIGH, EID 25 tampering → HIGH. Channel absent = sensor absent, never degraded.
  • Beacon-shape recurrence — the same (binary, remote ip:port) pair live now AND seen in ≥3 scans spanning ≥45 min, from a non-browser/non-trusted-prefix binary that is unsigned-or-user-writable → HIGH. Interval polling is genuinely good at this key; browsers churn, beacons persist.
  • Obfuscated-payload argv — interpreter + inline-code flag + (≥100-char decodable base64 run at ≥4.5 bits/char entropy, or an in-process decode+exec composition) → MEDIUM alone, HIGH with fetch/exec co-occurrence. Measured on this machine: 611 live processes, 0 false positives (the one benign class found — hex-UUID paths — is excluded by an alphabet-purity rule and pinned by test).

New opt-in tiers

  • intel update|status — community IOC layer (MalwareBazaar + ThreatFox, no key). By-hand fetch only, lazy urllib exactly like vt; 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 existing cmd_* 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.
  • Menu-bar plugin (menubar/aegis-status.30s.py) — xbar/SwiftBar, stdlib-only, structurally read-only (before/after inventory pinned across all 19 tests). 🛡️ healthy / ⚠️ N incidents / 💀 monitor-dead — the dead-monitor state is the reason it exists.

Honest limits carried forward

  • The new Windows registry/Sysmon probes are fixture-proven; this PR's CI is their first real-kernel run.
  • Root install/uninstall halves are stubbed in tests (euid 0); generation, shape, and staleness logic are fully tested.
  • The beacon OR-gate can one-time-flag a signed long-lived daemon in a risky prefix (e.g. a brew daemon holding one endpoint); mitigated by one-incident dedup and its own dismissal category (net-beacon).
  • Feeds are recent-window deltas (~48h), a freshness layer, not a historical corpus.

🤖 Generated with Claude Code

opencdlee-dotcom and others added 18 commits August 11, 2026 08:18
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>
…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>
…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>
@opencdlee-dotcom
opencdlee-dotcom merged commit b3d0b61 into main Aug 11, 2026
5 checks passed
@opencdlee-dotcom
opencdlee-dotcom deleted the feat/doit-integration branch August 11, 2026 23:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant