From 99da774424e12b96a50daa44428a850319f80da8 Mon Sep 17 00:00:00 2001 From: Deva Date: Sat, 25 Jul 2026 17:39:20 +0530 Subject: [PATCH] =?UTF-8?q?v0.4.7:=20ephemeral=20port=20rotation=20is=20ch?= =?UTF-8?q?urn=20=E2=80=94=20judged=20on=20the=20change,=20not=20the=20ove?= =?UTF-8?q?rlap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by dogfooding, one hour into the three-day trial, on a real Mac. Six adversarial review rounds did not find it, because it is not a logic error you can see by reading — it needs a machine where Apple's `rapportd` actually rotates its ports. Measured on the trial machine: rapportd 57905,65426,65427 -> 57905,65428,65429 (keeps one, rotates two) v0.4.6 asked "do the port sets overlap?" to decide churn. They do overlap here, so it called this a real signal and reported it at ORANGE — which crosses the --notify threshold. On a normal Mac that is a desktop notification every few hours forever, and a digest that cries wolf every few hours is one nobody reads. That failure mode is worse for this tool than most missed detections, because it disables the reader. Churn is now a balanced ROTATION entirely inside the ephemeral range (>=32768), decided on what CHANGED rather than on set overlap. A net GAIN is never suppressed whatever the port number — including ephemeral ports, since malware binds those too (`sshd 22 -> 22,49999` still reports). The three real rotations observed on the trial machine are now test fixtures, and the five gain/rebind cases from the v0.4.6 churn fix still report. Mutations both caught: reverting to the overlap test, and suppressing net gains. Suite 658 -> 667. Co-Authored-By: Claude --- CHANGELOG.md | 20 ++++++++++++++++++++ README.md | 2 +- pyproject.toml | 2 +- since.py | 22 +++++++++++++--------- tests/test_since.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 11 deletions(-) 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