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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 # 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
```

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.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"
Expand Down
61 changes: 51 additions & 10 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.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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)."""
Expand All @@ -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():
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down
63 changes: 63 additions & 0 deletions tests/test_since.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading