Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,10 @@ guards because they answer the same adversarial pressures:
rules instead, and machine verdicts write no dismissal record, so tolerance
can never feed on itself or pollute `backtest` precision.
- **Antigen-specific**: the identity is the fingerprint minus its trailing
content hash, and only categories whose benign churn is hash-shaped are
eligible (an allowlist). A beacon's endpoint, a path, a marker set are facts —
content hash, with dotted version segments normalized inside path-like
fields only (a vendor's versioned install dir renames every release), and
only categories whose benign churn is hash- or version-shaped are eligible
(an allowlist). A beacon's endpoint, a base path, a marker set are facts —
a new one is a new incident, always.
- **Repeated exposure required**: three distinct dismissed incidents on the
identity inside 180 days, so one hasty dismissal teaches nothing.
Expand Down
109 changes: 90 additions & 19 deletions aegis.py
Original file line number Diff line number Diff line change
Expand Up @@ -2591,28 +2591,54 @@ def _category_dismissal_weights(db, now, window=90 * 86400):
_TOLERANCE_WINDOW = 180 * 86400
# A trailing :<hex> component of this shape is a content hash, not identity.
_TOLERANCE_HASH_RE = re.compile(r"^[0-9a-f]{12,64}$", re.I)
# Only surfaces whose benign churn is hash-shaped may generalize at all —
# an allowlist, matching the deadfall discipline, never a blocklist.
# Version churn is path churn: a vendor's versioned install dir renames on
# every release (claude-code-2.1.226 -> .228, runner bin.2.336.0, a framework's
# Versions/3.12), so the same reviewed binary presents a "new" path per update.
# Dotted version segments are normalized to '#' — but ONLY inside path-like
# ':'-fields (ones containing '/'). An ip, port, or trust verdict is its own
# ':'-field with no '/', so endpoints and verdicts stay literal: a new
# endpoint is a new fact and always alerts. Same precedent the hash-strip set
# (same path, same trust class, new content); risk-kind incidents remain
# never-tolerated and the process sensor grades signatures independently.
_TOLERANCE_VERSION_RE = re.compile(
r"(?<![0-9A-Za-z])\d+(?:\.\d+){1,3}(?![0-9A-Za-z])")
# Only surfaces whose benign churn is hash- or version-shaped may generalize
# at all — an allowlist, matching the deadfall discipline, never a blocklist.
_TOLERANCE_CATEGORIES = frozenset((
"persistence", "xpersist", "process", "behavior", "agent-surface", "btm"))
"persistence", "xpersist", "process", "behavior", "agent-surface", "btm",
"net-beacon"))
# Attack-defined evidence (deadfall's own trigger prefixes plus honeytokens):
# no verdict history may ever tolerize these.
_NEVER_TOLERATE_PREFIXES = ("decoy:", "latch:", "canary:")


def _tolerance_identity(fingerprint):
"""The hash-stripped stable identity of a signal fingerprint, or None when
no safe generalization exists. Strips exactly ONE trailing content-hash
component; a fingerprint with no hash suffix (beacons end in a port, some
process rows carry sha None) has nothing to generalize over and the
existing exact-key reattach already covers it."""
"""The stable identity of a signal fingerprint, or None when no safe
generalization exists. Two — and only two — mutations generalize: a
trailing content-hash component is stripped, and dotted version segments
inside path-like fields are normalized. A fingerprint neither touches
(beacons at a stable path, some process rows carry sha None) has nothing
to generalize over and the existing exact-key reattach already covers it."""
fp = str(fingerprint or "")
if not fp or fp.startswith(_NEVER_TOLERATE_PREFIXES):
return None
parts = fp.split(":")
if len(parts) < 3 or not _TOLERANCE_HASH_RE.match(parts[-1]):
if len(parts) < 3:
return None
return ":".join(parts[:-1])
changed = False
if _TOLERANCE_HASH_RE.match(parts[-1]):
parts = parts[:-1]
changed = True
normalized = []
for part in parts:
if "/" in part:
versionless = _TOLERANCE_VERSION_RE.sub("#", part)
changed = changed or (versionless != part)
part = versionless
normalized.append(part)
if not changed or len(normalized) < 2:
return None
return ":".join(normalized)


def _tolerance_memory(db, now):
Expand Down Expand Up @@ -4886,16 +4912,60 @@ def check_xprotect(window_hours=None):
if newest is not None:
age_days = (time.time() - newest) / 86400.0
if age_days > XPROTECT_STALE_DAYS:
findings.append(finding(
"MEDIUM", "xprotect", "XProtect definitions are stale",
"XProtect (v%s) last updated %.0f days ago (> %d). Apple ships "
"updates roughly monthly; a long gap suggests a broken update "
"path or MDM interference. Check Software Update."
% (version or "?", age_days, XPROTECT_STALE_DAYS),
"xprotect:stale:%s" % (version or "unknown")))
# Corpus age alone cannot distinguish the two opposite diagnoses:
# a broken update path (fix Software Update / MDM) vs Apple simply
# not shipping (nothing to fix locally). The updater's own
# heartbeat can: `xprotect version` prints when the OS last
# (re)installed the corpus — fresh install of an old corpus proves
# the path works.
updater_age = _xprotect_updater_age_days()
if updater_age is not None and \
updater_age <= _XPROTECT_UPDATER_FRESH_DAYS:
findings.append(finding(
"INFO", "xprotect",
"XProtect corpus is old but the update path is alive",
"XProtect (v%s) corpus is %.0f days old (> %d), but the OS "
"updater (re)installed it %.0f day(s) ago — the update "
"path works and Apple has not shipped a newer corpus. "
"Nothing to fix locally."
% (version or "?", age_days, XPROTECT_STALE_DAYS,
updater_age),
"xprotect:stale:%s" % (version or "unknown")))
else:
findings.append(finding(
"MEDIUM", "xprotect", "XProtect definitions are stale",
"XProtect (v%s) last updated %.0f days ago (> %d). Apple "
"ships updates roughly monthly; a long gap suggests a "
"broken update path or MDM interference. Check Software "
"Update." % (version or "?", age_days, XPROTECT_STALE_DAYS),
"xprotect:stale:%s" % (version or "unknown")))
return findings


_XPROTECT_UPDATER_FRESH_DAYS = 14


def _xprotect_updater_age_days():
"""Days since the OS updater last (re)installed the XProtect corpus, from
`xprotect version` (present on modern macOS), or None where unavailable.
The 'Installed' stamp is the update MECHANISM's heartbeat, independent of
whether the corpus content changed."""
out, _, rc = run(["/usr/bin/xprotect", "version"], timeout=6)
if rc != 0 or not out:
return None
m = re.search(r"Installed:\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s*"
r"\+0000", out)
if not m:
return None
try:
installed = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")
installed = installed.replace(tzinfo=timezone.utc)
except ValueError:
return None
return max(0.0, (datetime.now(timezone.utc) - installed).total_seconds()
/ 86400.0)


# --------------------------------------------------------------------------- #
# Check 2d: shell HISTORY — ClickFix terminal-paste residue.
#
Expand Down Expand Up @@ -17305,8 +17375,9 @@ def cmd_guard(action="status", rest=None):
Acquired tolerance: an identity you have dismissed
benign-positive on 3+ distinct incidents re-opens PRE-CLOSED
(TOLERATED, evidence kept, no alert) when only its content
hash changed — never on higher severity, a new endpoint, or
attack-defined evidence. `reopen` disputes + revokes it
hash or a version segment in its path changed — never on
higher severity, a new endpoint, or attack-defined
evidence. `reopen` disputes + revokes it
replay [days] backtest the CURRENT correlation logic against recorded
history (default 30d). Read-only: opens no incident, sends
no notification — run it after changing detection logic
Expand Down
54 changes: 54 additions & 0 deletions tests/test_tolerance.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,35 @@ def test_non_hash_suffixes_do_not_generalize(self):
"short:ab"):
self.assertIsNone(aegis._tolerance_identity(fp), fp)

def test_version_segments_in_paths_generalize(self):
# A vendor's versioned install dir renames every release; the same
# reviewed binary must map to ONE identity across versions...
v226 = aegis._tolerance_identity(
"beacon:/x/extensions/vendor.tool-2.1.226-darwin-arm64/bin/tool"
":160.79.104.10:443")
v228 = aegis._tolerance_identity(
"beacon:/x/extensions/vendor.tool-2.1.228-darwin-arm64/bin/tool"
":160.79.104.10:443")
self.assertIsNotNone(v226)
self.assertEqual(v226, v228)
# ...while the ENDPOINT is its own ':'-field with no '/': it is never
# normalized, so a new ip:port is a new identity and always alerts.
self.assertIn("160.79.104.10:443", v226)
other_ip = aegis._tolerance_identity(
"beacon:/x/extensions/vendor.tool-2.1.228-darwin-arm64/bin/tool"
":35.190.46.17:443")
self.assertNotEqual(v228, other_ip)

def test_version_and_hash_compose_for_process_fingerprints(self):
a = aegis._tolerance_identity(
"process:/u/actions-runners/os/bin.2.336.0/Runner.Worker:adhoc:"
+ "a" * 64)
b = aegis._tolerance_identity(
"process:/u/actions-runners/os/bin.2.340.1/Runner.Worker:adhoc:"
+ "b" * 64)
self.assertIsNotNone(a)
self.assertEqual(a, b)

def test_attack_defined_prefixes_never_tolerize(self):
for fp in ("decoy:read:/home/x/.aws/credentials:" + "b" * 16,
"latch:cleared:/Library/LaunchAgents:" + "c" * 16,
Expand Down Expand Up @@ -211,6 +240,31 @@ def test_stale_verdicts_expire(self):
self._ingest(_finding(fp), NOW)
self.assertEqual(self._incident_for(fp)["status"], "OPEN")

def test_beacon_version_churn_earns_tolerance_end_to_end(self):
base = ("beacon:/u/ext/vendor.tool-2.1.%d-darwin-arm64/bin/tool"
":160.79.104.10:443")
for i in range(3):
fp = base % (226 + i)
self._ingest(_finding(fp, category="net-beacon",
title="Persistent outbound connection"),
NOW + i * 60)
aegis.transition_incident(
self._incident_for(fp)["id"], "FALSE_POSITIVE",
now=NOW + i * 60 + 30, reason_code="benign-positive")
fp = base % 240
self._ingest(_finding(fp, category="net-beacon",
title="Persistent outbound connection"),
NOW + 3600)
self.assertEqual(self._incident_for(fp)["resolution"],
"auto-tolerated")
# Same binary, NEW endpoint: a new fact — must open and alert.
fp_new_ip = ("beacon:/u/ext/vendor.tool-2.1.240-darwin-arm64/bin/tool"
":203.0.113.9:443")
self._ingest(_finding(fp_new_ip, category="net-beacon",
title="Persistent outbound connection"),
NOW + 3700)
self.assertEqual(self._incident_for(fp_new_ip)["status"], "OPEN")

def test_tolerated_incident_is_visible_and_counted(self):
self._teach(3)
fp = "persistence:changed:%s:%016x" % (PLIST, 99)
Expand Down
47 changes: 47 additions & 0 deletions tests/test_xprotect_freshness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""XProtect staleness must distinguish its two opposite diagnoses.

Corpus age alone conflates "the update path is broken — go fix Software
Update/MDM" with "the updater is alive and Apple has not shipped — nothing to
fix locally". The `xprotect version` Installed stamp is the updater's own
heartbeat and separates them; these tests pin its parsing (the branch that
consumes it is a plain if/else on the returned age).
"""
import os
import sys
import unittest

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import aegis # noqa: E402

_REAL_OUTPUT = "Version: 5355 Installed: 2026-08-12 03:37:51 +0000"


class TestXprotectUpdaterAge(unittest.TestCase):
def setUp(self):
self._run = aegis.run

def tearDown(self):
aegis.run = self._run

def test_parses_the_real_output_shape(self):
aegis.run = lambda cmd, timeout=15, extra_env=None: (_REAL_OUTPUT, "", 0)
age = aegis._xprotect_updater_age_days()
self.assertIsNotNone(age)
self.assertGreaterEqual(age, 0.0)

def test_unavailable_cli_returns_none(self):
# None -> the caller keeps the conservative MEDIUM "check Software
# Update" diagnosis; absence of the heartbeat is never read as fresh.
aegis.run = lambda cmd, timeout=15, extra_env=None: ("", "no such", 1)
self.assertIsNone(aegis._xprotect_updater_age_days())

def test_garbage_or_non_utc_output_returns_none(self):
for out in ("Version: 5355", "Installed: yesterday",
"Installed: 2026-08-12 03:37:51 +0900"):
aegis.run = lambda cmd, timeout=15, extra_env=None, o=out: (o, "", 0)
self.assertIsNone(aegis._xprotect_updater_age_days(), out)


if __name__ == "__main__":
unittest.main()
Loading