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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 13 additions & 9 deletions since.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions tests/test_since.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading