From ccf16e60400a87ddfdf441fb74de44e0c4f25ee9 Mon Sep 17 00:00:00 2001 From: Deva Date: Tue, 28 Jul 2026 01:23:41 +0530 Subject: [PATCH] v0.4.9: fix the two defects the 3-day trial found on real machines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both came from watching real output over days, not from review. 1. Upgrading to 0.4.8 set off a false alarm on every listener. 0.4.8 began storing `*:5000` where older snapshots held `5000`; the diff compared them as opaque strings, so the first post-upgrade run reported 11 "changed listener" findings on the trial Mac and 3 on the Linux box — all ORANGE, all notifying, all false: the ports were identical. The listening diff now compares port sets when either side predates the change. Not a blanket exemption: a genuinely new port arriving with the migration is still reported, and once both sides carry addresses, 127.0.0.1:5000 -> *:5000 remains a real exposure finding. Both guarantees are tested, and both tests were mutation-checked. 2. The Linux installer promised a daily job it could not 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 it down at your last logout and the timer goes with it — so the installer printed a confident success for a job that goes dormant on exactly the machine that needs it most: a server nobody logs into. It now checks Linger after a successful enable and prints the one-line fix. Verified on a real Ubuntu box in both directions. Suite 689 -> 692. Co-Authored-By: Claude --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ install.sh | 12 ++++++++++++ since.py | 11 ++++++++++- tests/test_since.py | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) 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"