diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dce9609..80df561 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/aegis.py b/aegis.py index 761403c..1ca184c 100755 --- a/aegis.py +++ b/aegis.py @@ -2591,28 +2591,54 @@ def _category_dismissal_weights(db, now, window=90 * 86400): _TOLERANCE_WINDOW = 180 * 86400 # A trailing : 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"(? 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. # @@ -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 diff --git a/tests/test_tolerance.py b/tests/test_tolerance.py index 837502f..c7ddfc0 100644 --- a/tests/test_tolerance.py +++ b/tests/test_tolerance.py @@ -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, @@ -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) diff --git a/tests/test_xprotect_freshness.py b/tests/test_xprotect_freshness.py new file mode 100644 index 0000000..92869d7 --- /dev/null +++ b/tests/test_xprotect_freshness.py @@ -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()