diff --git a/CHANGELOG.md b/CHANGELOG.md index f832d67..1f4cad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to `since`. Format loosely follows Keep a Changelog. +## [0.4.8] — 2026-07-26 + +Day 1 of a three-day trial on a real developer's Mac. Every fix below came from watching actual +output, not from review — and the biggest one is a capability the tool always had the data for +and threw away. Suite 667 → **689**. + +**Listeners now record WHERE they are bound, and severity follows reachability.** `lsof` and `ss` +report `127.0.0.1:64991`; the collector kept only `64991`. So `claude` on loopback was reported at +the same severity as `*:4444` — and on a developer machine `java`, `node`, `claude` and `python` +bind loopback constantly, which was the dominant source of listener noise. A new listener bound +only to loopback is now YELLOW: visible in the report, below the `--notify` threshold. **Any** +non-local binding (`*`, `0.0.0.0`, a LAN address) stays ORANGE, a mixed set stays ORANGE, and a +bare port from an older snapshot is treated as *unknown* and stays ORANGE — unknown is never +assumed safe. + +**Build outputs are pruned from the big-file scan.** One Rust workspace produced +772 MB across 15 +`dep-graph.bin`/`.rlib` entries in a single day, crowding out anything a person would want to see. +`target`, `build`, `dist`, `Pods`, `__pycache__`, `.venv`, `venv` join `node_modules` and friends. + +**Changed software gets its "why" too.** `claude-code 2.1.206 → 2.1.212` was reported with no +attribution while `brew upgrade claude-code` sat one line up in the shell history — attribution ran +only for `added`, not `changed`. + +*Upgrade note: listener values change from `4444` to `addr:4444`, so listeners appear once as +removed+added.* + ## [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 diff --git a/README.md b/README.md index b677bd8..e511bcc 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ silent changes visible. ```sh python3 -m pip install pytest -python3 -m pytest # 667 tests (491 example-based + 176 property): diff/severity/time logic, injection-safety, +python3 -m pytest # 689 tests (513 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 00401d4..57049c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "since-cli" -version = "0.4.7" +version = "0.4.8" 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 90bd564..ed0ac68 100755 --- a/since.py +++ b/since.py @@ -59,7 +59,7 @@ from datetime import datetime, timedelta from pathlib import Path -__version__ = "0.4.7" +__version__ = "0.4.8" 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 @@ -816,8 +816,10 @@ def _listening(): elif tag == "n" and cur is not None: m = _PORT_F_RE.search(val) if m: - by_cmd[cur].add(m.group(1)) - return {c: ",".join(sorted(p, key=lambda x: (len(x), x))) for c, p in by_cmd.items() if p} + # `val` is e.g. `127.0.0.1:64991`, `*:8080`, `[::1]:5000` — keep it whole. + by_cmd[cur].add(val.strip()) + return {c: ",".join(sorted(p, key=lambda x: (len(binding_port(x)), x))) + for c, p in by_cmd.items() if p} def _outbound(): @@ -1097,6 +1099,26 @@ def _linux_browser_extensions(): _SS_PROC_RE = re.compile(r'users:\(\("([^"]+)"') +# A listener is stored as `addr:port`, because WHERE it is bound decides whether it is +# reachable at all. Measured on a real dev machine during the trial run: `java`, `node`, +# `claude` and friends bind 127.0.0.1 constantly, and reporting those at the same severity as +# `*:4444` is the main source of listener noise — while the bind address was sitting unused in +# the `lsof`/`ss` output we already parse. Bare ports (pre-v0.4.8 snapshots) are tolerated and +# treated as NOT known-local, i.e. they keep the louder severity. +_LOOPBACK_PREFIXES = ("127.", "::1", "[::1]", "localhost") + + +def binding_port(b: str) -> str: + """The port from an `addr:port` binding, or the bare value if there is no address.""" + return b.rsplit(":", 1)[-1].strip("[]") if ":" in b else b + + +def binding_is_local(b: str) -> bool: + """True only when we KNOW the binding is loopback — unknown stays loud.""" + if ":" not in b: + return False # a bare port from an older snapshot: unknown + addr = b.rsplit(":", 1)[0] + return addr.startswith(_LOOPBACK_PREFIXES) def _linux_listening(): """ss -ltnp: listening TCP keyed by process (falls back to lsof if ss absent).""" @@ -1108,10 +1130,10 @@ def _linux_listening(): parts = line.split() if len(parts) < 4: continue - port = parts[3].rsplit(":", 1)[-1] m = _SS_PROC_RE.search(line) - by_cmd.setdefault(m.group(1) if m else "?", set()).add(port) - return {c: ",".join(sorted(p, key=lambda x: (len(x), x))) for c, p in by_cmd.items() if p} + by_cmd.setdefault(m.group(1) if m else "?", set()).add(parts[3].strip()) + return {c: ",".join(sorted(p, key=lambda x: (len(binding_port(x)), x))) + for c, p in by_cmd.items() if p} def _linux_outbound(): @@ -1582,7 +1604,13 @@ def resolve_baseline(arg: str | None) -> tuple[Path | None, str]: # big new files + fastest-growing dirs (computed live vs baseline time) # --------------------------------------------------------------------------- -BIGFILE_PRUNE = {"Library", "node_modules", "DerivedData", +# Build outputs are pruned for the same reason node_modules is: on a developer machine they +# dominate the "biggest new files" list with pure churn. Measured on the trial machine — one Rust +# workspace produced +772MB across 15 `dep-graph.bin`/`.rlib` entries in a day, crowding out +# anything a person would actually want to see. This section is informational (GREEN tier) and +# the report already states that it only covers visible locations. +BIGFILE_PRUNE = {"target", "build", "dist", "Pods", "__pycache__", ".venv", "venv", + "Library", "node_modules", "DerivedData", "Photos Library.photoslibrary", "Music Library.musiclibrary"} def find_big_new_files(since_epoch: int, min_mb: int = 25, top: int = 15): @@ -1974,6 +2002,14 @@ def build_findings(baseline: dict, current: dict, include_quiet=False, skip_cats # quiet-tier categories (outbound) are informational only — never # let them reach the ranked "worth a look" section or fire a notify. level = GREEN if meta["tier"] == "quiet" else base_level(meta["cls"], action) + if key == "listening" and action == "added" and isinstance(v, str): + # Loopback-only: visible in the report, but it does NOT cross the --notify + # threshold. A process reachable only from this machine is a different claim + # from one listening on every interface, and conflating them is what made a + # developer's java/node/claude churn indistinguishable from `*:4444`. + binds = [b for b in v.split(",") if b] + if binds and all(binding_is_local(b) for b in binds): + level = YELLOW extra = {} if key == "listening" and action == "changed": # A daemon that rebinds ALL its ports each boot (rapportd) shares @@ -1996,8 +2032,9 @@ def build_findings(baseline: dict, current: dict, include_quiet=False, skip_cats # # 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) + def _eph(bindings): + return all(binding_port(b).isdigit() and int(binding_port(b)) >= 32768 + for b in bindings if b) if added_ports and removed_ports and _eph(added_ports) and _eph(removed_ports): continue level = ORANGE if added_ports else YELLOW @@ -2139,7 +2176,11 @@ def _enrich(f: dict, current: dict): if suspicious: f["level"] = RED # attribution for software installs - if cat in ("brew", "npm_global", "pip", "applications", "mac_app_store") and action == "added": + # "changed" too, not just "added": a version bump is a software change a user asks "why?" + # about, and the answer is usually one line up in their shell history. Measured on the trial: + # `claude-code 2.1.206 -> 2.1.212` arrived with why=None while `brew upgrade` sat in history. + if (cat in ("brew", "npm_global", "pip", "applications", "mac_app_store") + and action in ("added", "changed")): # bare_key: a tagged key (`foo (cask)`, `foo (snap)`) can never whole-word-match # a history line, so casks/snaps/flatpaks silently got no "why" at all. f["why"] = attribution_for(bare_key(key)) diff --git a/tests/test_since.py b/tests/test_since.py index 7a2efcb..c5f9445 100644 --- a/tests/test_since.py +++ b/tests/test_since.py @@ -2091,3 +2091,66 @@ def test_net_port_gain_is_always_reported(before, after, why): snap(collectors={"listening": after})) if x["category"] == "listening"] assert f and f[0]["level"] >= since.ORANGE, why + + +# =========================================================================== # +# Day 1 of the live trial. None of these came from adversarial review — they # +# came from watching real output on a real developer's machine. # +# =========================================================================== # + +# The bind address was in the lsof/ss output we already parse, and we threw it away — so +# `claude` on 127.0.0.1:64991 was reported at the same severity as `*:4444`. On a dev machine +# java/node/claude/python bind loopback constantly, and that was the main listener noise. +@pytest.mark.parametrize("binding,expect_local", [ + ("127.0.0.1:64991", True), ("127.0.0.53:53", True), ("[::1]:8080", True), + ("localhost:3000", True), + ("*:4444", False), ("192.168.1.5:4444", False), ("0.0.0.0:22", False), + ("4444", False), # bare port from a pre-v0.4.8 snapshot: unknown => loud +]) +def test_binding_locality(binding, expect_local): + assert since.binding_is_local(binding) is expect_local + assert since.binding_port(binding) in ("64991", "53", "8080", "3000", "4444", "22") + + +@pytest.mark.parametrize("bindings,level,why", [ + ("127.0.0.1:64991", "YELLOW", "the trial's actual noise: not reachable off-machine"), + ("127.0.0.1:60325,127.0.0.1:60329", "YELLOW", "multi-port loopback (java on the trial box)"), + ("*:4444", "ORANGE", "reachable from anywhere"), + ("192.168.1.5:4444", "ORANGE", "reachable on the LAN"), + ("127.0.0.1:8080,*:9090", "ORANGE", "ANY non-local binding keeps it loud"), + ("4444", "ORANGE", "unknown locality must stay loud, not be assumed safe"), +]) +def test_new_listener_severity_follows_reachability(bindings, level, why): + f = [x for x in since.build_findings(snap(), snap(collectors={"listening": {"p": bindings}})) + if x["category"] == "listening"] + assert f, why + assert f[0]["level"] == (since.YELLOW if level == "YELLOW" else since.ORANGE), why + # YELLOW must stay below the --notify threshold; ORANGE must reach it + assert (f[0]["level"] >= since.ORANGE) == (level == "ORANGE") + + +def test_rotation_suppression_still_works_on_addr_port_bindings(): + b = snap(collectors={"listening": {"x": "127.0.0.1:65426,127.0.0.1:65427"}}) + c = snap(collectors={"listening": {"x": "127.0.0.1:65428,127.0.0.1:65429"}}) + assert not [f for f in since.build_findings(b, c) if f["category"] == "listening"] + # …but gaining a publicly-bound port is still reported + d = snap(collectors={"listening": {"x": "127.0.0.1:65426,*:4444"}}) + f = [x for x in since.build_findings(b, d) if x["category"] == "listening"] + assert f and f[0]["level"] >= since.ORANGE + + +# A Rust workspace produced +772MB across 15 dep-graph.bin/.rlib entries in one day on the trial +# machine, crowding the "biggest new files" list with pure build churn. +@pytest.mark.parametrize("d", ["target", "build", "dist", "Pods", "__pycache__", "node_modules"]) +def test_build_dirs_are_pruned_from_the_big_file_scan(d): + assert d in since.BIGFILE_PRUNE + + +# `claude-code 2.1.206 -> 2.1.212` arrived with why=None while `brew upgrade` sat in the history. +def test_changed_software_gets_attribution(monkeypatch): + monkeypatch.setattr(since, "_HISTORY", ["cd ~/dev", "brew upgrade claude-code", "ls"]) + f = {"category": "brew", "action": "changed", "key": "claude-code", + "value": ("2.1.206", "2.1.212"), "level": since.GREEN, "label": "brew package", + "trust": None, "why": None, "undo": None} + since._enrich(f, {}) + assert f["why"] == "brew upgrade claude-code"