diff --git a/CHANGELOG.md b/CHANGELOG.md index dfdc8a3..f832d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to `since`. Format loosely follows Keep a Changelog. +## [0.4.7] — 2026-07-25 + +Found by **dogfooding**, one hour into a three-day trial run on a real Mac — not by any of the six +adversarial review rounds. + +**A listener rotating ephemeral ports no longer fires a notification every few hours.** Apple's +`rapportd` keeps one port and rotates the others (measured: `57905,65426,65427` → +`57905,65428,65429`). v0.4.6 judged churn by whether the port sets OVERLAP, so a rotation that +retained one port was treated as a real signal — ORANGE, which crosses the `--notify` threshold. +On a normal Mac that is a desktop notification every few hours, forever, which is precisely how a +digest teaches its reader to ignore it. + +Churn is now a balanced **rotation** entirely inside the ephemeral range (≥32768), judged on what +*changed* rather than on set overlap. A net **gain** is never suppressed — whatever the port +number, including ephemeral ones, because malware binds those too. The four real rotations +measured on the trial machine are now regression fixtures, alongside the five gain/rebind cases +from v0.4.6 that must still report. + +Suite 658 → **667**. + ## [0.4.6] — 2026-07-25 A **fourth** adversarial round, pointed for the first time at the surfaces the previous five had diff --git a/README.md b/README.md index 3924bf3..b677bd8 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ silent changes visible. ```sh python3 -m pip install pytest -python3 -m pytest # 656 tests (480 example-based + 176 property): diff/severity/time logic, injection-safety, +python3 -m pytest # 667 tests (491 example-based + 176 property): diff/severity/time logic, injection-safety, # privilege guard, corruption tolerance, secret redaction ``` diff --git a/pyproject.toml b/pyproject.toml index 2727bc8..00401d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "since-cli" -version = "0.4.6" +version = "0.4.7" description = "A plain-language, severity-ranked daily diff of your Mac or Linux box — startup items, listeners, packages, big new files, and edited system files." readme = "README.md" requires-python = ">=3.9" diff --git a/since.py b/since.py index e622a28..90bd564 100755 --- a/since.py +++ b/since.py @@ -59,7 +59,7 @@ from datetime import datetime, timedelta from pathlib import Path -__version__ = "0.4.6" +__version__ = "0.4.7" SCHEMA_VERSION = 5 # 4: snap['tools'] (tool identity); 5: snap['blob_flags'] if sys.version_info < (3, 9): # uses PEP 585 generics in annotations + os.replace @@ -1987,14 +1987,18 @@ def build_findings(baseline: dict, current: dict, include_quiet=False, skip_cats removed_ports = sorted(old_ports - new_ports) if not (added_ports or removed_ports): continue # nothing actually changed - # Churn suppression must be NARROW. "no overlap => churn" dropped a - # single-port service rebinding (8080 -> 4444) and a backdoor sharing a - # churny process name (rapportd 49152 -> 49157,4444) — both silently, in the - # highest-signal category. Only a multi-port set that is ENTIRELY ephemeral - # is churn; anything with a well-known port is reported. - ephemeral = all(pt.isdigit() and int(pt) >= 32768 - for pt in (old_ports | new_ports) if pt) - if not (old_ports & new_ports) and ephemeral and len(old_ports) > 1: + # Churn is a balanced ROTATION entirely inside the ephemeral range — judged on + # what CHANGED, not on whether the sets overlap. Measured on a real machine: + # Apple's `rapportd` keeps one port and rotates two others every few hours + # (57905,65426,65427 -> 57905,65428,65429). An overlap test called that a real + # signal, so it fired ORANGE — and therefore a desktop notification — every + # few hours forever, which is how a digest teaches its reader to ignore it. + # + # A net GAIN is never suppressed, whatever the port number: malware binding a + # random high port adds without removing, so it still reports. + def _eph(ports): + return all(pt.isdigit() and int(pt) >= 32768 for pt in ports if pt) + if added_ports and removed_ports and _eph(added_ports) and _eph(removed_ports): continue level = ORANGE if added_ports else YELLOW if added_ports: diff --git a/tests/test_since.py b/tests/test_since.py index 0be95b6..7a2efcb 100644 --- a/tests/test_since.py +++ b/tests/test_since.py @@ -2059,3 +2059,35 @@ def test_json_output_is_always_json(tmp_path, extra): assert payload["baseline"] is None and payload["findings"] == [] assert payload["notes"] and "no baseline" in payload["notes"][0] assert (payload["first_snapshot"] is None) == bool(extra) + + +# From the live trial, hour one: Apple's `rapportd` keeps one port and rotates two others every +# few hours (57905,65426,65427 -> 57905,65428,65429). Because the sets OVERLAP, the round-4 rule +# called it a real signal and fired ORANGE — and therefore a desktop notification — every few +# hours. Churn is a balanced ROTATION inside the ephemeral range, judged on what CHANGED. +@pytest.mark.parametrize("before,after", [ + ("57905,65426,65427", "57905,65428,65429"), # measured on the real machine + ("57905,65428,65429", "59858,65469,65470"), # measured (full turnover) + ("59858,65469,65470", "59858,65471,65472"), # measured (the line the user was shown) + ("49152,49153", "49160,49161"), +]) +def test_ephemeral_port_rotation_is_churn(before, after): + b = snap(collectors={"listening": {"rapportd": before}}) + c = snap(collectors={"listening": {"rapportd": after}}) + assert not [f for f in since.build_findings(b, c) if f["category"] == "listening"], \ + "ephemeral rotation must not notify — this is what teaches a user to ignore the digest" + + +@pytest.mark.parametrize("before,after,why", [ + ({"svc": "8080"}, {"svc": "4444"}, "single-port rebind to a well-known port"), + ({"rapportd": "49152"}, {"rapportd": "49157,4444"}, "backdoor under a churny process name"), + ({"svc": "5000,6000"}, {"svc": "5001,6002"}, "non-ephemeral turnover"), + ({"sshd": "22"}, {"sshd": "22,4444"}, "net gain, well-known port"), + ({"sshd": "22"}, {"sshd": "22,49999"}, "net gain, EPHEMERAL port — malware binds these too"), +]) +def test_net_port_gain_is_always_reported(before, after, why): + """A gain without a matching loss is never suppressed, whatever the port number.""" + f = [x for x in since.build_findings(snap(collectors={"listening": before}), + snap(collectors={"listening": after})) + if x["category"] == "listening"] + assert f and f[0]["level"] >= since.ORANGE, why