diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4cad0..a58376e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to `since`. Format loosely follows Keep a Changelog. +## [0.4.9] — 2026-07-27 + +Day 3 of the trial, and both fixes are things only a multi-day run on real machines could have +shown. Suite 689 → **692**. + +**Upgrading to 0.4.8 set off a false alarm on every listener.** 0.4.8 started storing `*:5000` +where older snapshots held `5000`, and the diff compared those as opaque strings — so the first +run after the upgrade reported 11 "changed listener" findings on the trial Mac and 3 on the Linux +box, all at ORANGE, all of them notifying, and every single one a lie: the ports were identical. +The changelog had predicted this churn and waved it through as harmless, which it was not. The +listening diff now compares what both sides actually express — when either side predates the +change it compares port sets, so a pure representation change produces nothing. This is *not* a +blanket exemption: a genuinely new port arriving alongside the migration is still reported, and +once both snapshots carry addresses, `127.0.0.1:5000` → `*:5000` is a real exposure change and +stays a finding. + +The general lesson, recorded in RULES.md: a change to how a collector *stores* a value must never +be able to look like a change in what it *observed*. + +**The Linux installer no longer promises a daily job it cannot keep.** `systemctl --user enable +--now` succeeding proves only that a user manager exists at that moment — and it exists because +you are logged in. With lingering off, systemd tears that manager down at your last logout and +the timer goes with it, so the installer printed a confident "enabled systemd user timer" for a +job that then goes dormant on exactly the machine that needs it most: a server nobody logs into. +(`Persistent=true` means the run is deferred to the next login rather than lost.) The installer +now checks `loginctl show-user ... -p Linger` after a successful enable and prints the one-line +fix when lingering is off. Verified on a real Ubuntu box in both directions: the note appears +with `Linger=no` and is absent after `enable-linger`. + ## [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 diff --git a/install.sh b/install.sh index a836f04..2c74e97 100755 --- a/install.sh +++ b/install.sh @@ -148,6 +148,18 @@ EOF if systemctl --user daemon-reload 2>/dev/null && systemctl --user enable --now since.timer 2>/dev/null; then echo " enabled systemd user timer: since.timer (output → journalctl --user -u since.service)" echo " verify with: systemctl --user list-timers since.timer" + # `enable --now` succeeding proves only that a user manager exists RIGHT NOW — and it + # exists because you are logged in. Without lingering, systemd tears that manager down at + # your last logout and the timer stops with it, so a "successfully enabled" daily job can + # quietly go dormant on exactly the machine that needs it most: a server nobody logs into. + # Persistent=true makes it catch up at the next login rather than lose the run outright. + if command -v loginctl >/dev/null 2>&1 && + [ "$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null)" = "no" ]; then + echo + echo " NOTE: lingering is off for this user, so the timer only runs while you are" + echo " logged in (it catches up at your next login). On a headless box, enable it:" + echo " sudo loginctl enable-linger $(id -un)" + fi else echo " wrote unit files to ${SYSTEMD_DIR}, but could NOT activate the timer" echo " (no user systemd session — common on headless boxes). To finish:" diff --git a/since.py b/since.py index ed0ac68..20b6772 100755 --- a/since.py +++ b/since.py @@ -59,7 +59,7 @@ from datetime import datetime, timedelta from pathlib import Path -__version__ = "0.4.8" +__version__ = "0.4.9" 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 @@ -2019,6 +2019,15 @@ def build_findings(baseline: dict, current: dict, include_quiet=False, skip_cats # stopped listening (YELLOW). Previously BOTH were dropped entirely. old_ports = set(str(v[0]).split(",")) new_ports = set(str(v[1]).split(",")) + # Migration-aware. v0.4.8 started storing `addr:port` where older snapshots + # held a bare port, and comparing `5000` with `*:5000` as opaque strings + # turned a REPRESENTATION change into a storm of "changed listener" findings + # — 11 in a single cycle on the trial machine, every one a false alarm, all + # at ORANGE, all notifying. When either side predates the change, compare + # what both sides actually express: the port set. + if any(":" not in b for b in old_ports | new_ports): + old_ports = {binding_port(b) for b in old_ports} + new_ports = {binding_port(b) for b in new_ports} added_ports = sorted(new_ports - old_ports) removed_ports = sorted(old_ports - new_ports) if not (added_ports or removed_ports): diff --git a/tests/test_since.py b/tests/test_since.py index c5f9445..6a5d274 100644 --- a/tests/test_since.py +++ b/tests/test_since.py @@ -2154,3 +2154,37 @@ def test_changed_software_gets_attribution(monkeypatch): "trust": None, "why": None, "undo": None} since._enrich(f, {}) assert f["why"] == "brew upgrade claude-code" + + +# Day 3 of the trial: v0.4.8 changed listener storage from `5000` to `*:5000`, and the diff +# compared those as opaque strings — so the upgrade itself produced 11 "changed listener" +# findings in one cycle, every one a false alarm, all ORANGE, all notifying. A representation +# change must never look like a security event. +def test_listener_storage_migration_is_not_a_finding(): + old = {"ControlCenter": "5000,7000", "adb": "5037", "postgres": "5432", + "Python": "8737,8899,8931"} # pre-v0.4.8 (bare ports) + new = {"ControlCenter": "*:5000,*:7000", "adb": "127.0.0.1:5037", + "postgres": "127.0.0.1:5432,[::1]:5432", "Python": "*:8737,*:8899,*:8931"} + f = [x for x in since.build_findings(snap(collectors={"listening": old}), + snap(collectors={"listening": new})) + if x["category"] == "listening"] + assert not f, f"the upgrade itself alarmed: {[(x['key'], x['value']) for x in f]}" + + +def test_migration_does_not_mask_a_real_new_port(): + """The compatibility path must not become a blind spot.""" + old = {"adb": "5037"} + new = {"adb": "127.0.0.1:5037,*:4444"} + f = [x for x in since.build_findings(snap(collectors={"listening": old}), + snap(collectors={"listening": new})) + if x["category"] == "listening"] + assert len(f) == 1 and f[0]["level"] >= since.ORANGE + assert "4444" in str(f[0].get("added_ports")) + + +def test_exposure_change_is_reported_once_both_sides_are_addressed(): + """Both sides new-format: localhost -> all-interfaces is a genuine exposure change.""" + f = [x for x in since.build_findings(snap(collectors={"listening": {"x": "127.0.0.1:5000"}}), + snap(collectors={"listening": {"x": "*:5000"}})) + if x["category"] == "listening"] + assert f, "a service becoming publicly reachable must be reported"