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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
11 changes: 10 additions & 1 deletion 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.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
Expand Down Expand Up @@ -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):
Expand Down
34 changes: 34 additions & 0 deletions tests/test_since.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading