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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ VPS-security.md
CLAUDE.md
PENDING.md
docs/
v2_kimi_findings.md
v3_kimi_findings.md

# Python build artifacts
build/
Expand Down
122 changes: 122 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,128 @@

All notable changes to `since`. Format loosely follows Keep a Changelog.

## [0.4.3] — 2026-07-25

Fixes for a **third** independent (Kimi) adversarial audit, which re-verified every v0.4.2
fix as genuine and then found one new High in the collector layer. Every fix below was
reproduced by execution first and is covered by a regression test (suite 90 → **129**).

**Security / availability (High):**
- **A planted FIFO or device symlink no longer hangs the daily digest.** Four collectors
(`~/Library/LaunchAgents` plists, XDG `.desktop` entries, and both browser-extension
manifest readers) read glob-matched paths with no regular-file check, so a FIFO — or a
symlink to `/dev/zero` — dropped into any of those *user-writable* directories blocked
`read()` **forever**, inside `take_snapshot()`, before any output, snapshot or
notification: `digest --notify` produced nothing and leaked one hung process per day.
A watchdog that dies silently is the exact failure this tool exists to prevent.
Regression-tested with real FIFOs under a SIGALRM deadline (the harness is self-checked:
it does catch an unguarded read). A non-regular entry in a persistence directory is now
*reported* ("not a regular file"), not silently skipped — such an entry is itself anomalous.
- **Every collector read is now bounded and race-proof (`safe_read_bytes`/`safe_read_text`).**
Auditing the fix above showed a type check alone was not enough: a **2GB sparse file**
planted as a plist costs an attacker nothing to create and drove **4.1GB peak RSS**
(2GB → 62MB after the fix), and a symlink swapped to a FIFO *after* the check re-opened the
hang. Reads now open with `O_NONBLOCK`, verify `S_ISREG` on the **file descriptor** (not the
path, so the check-then-open race cannot be won), and stop at 8MB. Shell history is read
from the *tail* (recency is what attribution needs) starting at a line boundary. Applied to
every plist/`.desktop`/manifest/rc-file/`/etc` read — snapshot output on a real machine is
byte-identical before and after.

**Secret hygiene / performance (Medium):**
- **`Authorization: <token>` is now masked.** The v0.4.2 HARD/SOFT key split had swept the
`authoriz` keyword into "always show" to keep `AuthorizedKeysFile` visible, which left a
raw `Authorization:` header (`.curlrc`/`.wgetrc`) printing in cleartext unless it happened
to use the `Basic`/`Bearer`/JWT shapes. Whole-word `authorization` is HARD; the
`Authorized*` sshd directives (absolute *and* relative paths) stay visible.
- **`redact()` cost is now bounded absolutely.** It was linear after v0.4.2 but carried a
~3–5µs/char constant, and `--json` redacts *every* diff line, so a few hundred KB of long
lines in a tracked rc file stalled the digest for tens of seconds. A shared-keyword linear
pre-filter short-circuits keyword-free lines (40KB: 128ms → 0.4ms) and every line is
capped at 4KB before the regexes run — truncation also fails safe, since the dropped tail
is never printed. The pre-filter and the matcher are generated from one keyword constant
so they cannot drift apart.

**Correctness (Low):**
- A sudoers **`PASSWD:` tag** no longer hides the command list it prefixes (`PASSWD:
/tmp/miner` was rendered `PASSWD: «redacted»` — the v0.4.2 carve-out covered only
`NOPASSWD:`). Value-shape gated, so a real `PASSWD=<secret>` assignment still redacts.
- **XDG autostart entries no longer collide across directories.** Keys were bare basenames,
so `/etc/xdg/autostart/x.desktop` silently overwrote — hid — a planted
`~/.config/autostart/x.desktop`. System entries are now tagged ` (system)` and their undo
hint points at the right directory with `sudo` (it previously pointed `rm` at `~/.config`,
where the file isn't). *One-time effect on Linux: existing `/etc/xdg` entries appear once
as removed+added as the keys change.*
- **Apps in `~/Applications` are trust-checked again.** The v0.4.2 same-name disambiguator
made `_enrich` build `…/Foo (~/Applications).app`, a path that never exists, so
`trust_of()` returned nothing and an unsigned/ad-hoc app there could never escalate to
RED. The new `bare_key()` also restores "why" attribution for tagged keys — a
`foo (cask)`/`foo (snap)` key could never whole-word-match a shell-history line.
- A **corrupt `labels.json`** that is valid JSON of the wrong type (`["a","b"]`) no longer
crashes `since mark` (`TypeError`) or `prune_snapshots` (`AttributeError`) — `load_labels()`
now shape-validates like `safe_load()`.
- `os.geteuid()`/`os.uname()` are no longer called at import, so on Windows the honest
"UNSUPPORTED PLATFORM" notice can actually print instead of a traceback.
- The big-file scan excluded the state directory by *substring*, which also excluded any
sibling directory whose name merely starts with it (`…/since_backup`) — now a path-prefix
match. The Linux browser-extension collector skips the `Temp` staging dir (macOS parity).

**Docs:** `SECURITY.md` gains an explicit **threat model** — a process running as you can
tamper with the baselines in `~/.local/state/since` and erase its own tracks, and helper
binaries are `PATH`-resolved (the daily job's minimal `PATH` is unaffected). Stale
`CLAUDE.md` state lines corrected.

## [0.4.2] — 2026-07-25

Fixes for a second independent (Kimi) adversarial audit — the v0.3.1 fix round and the
v0.4 Linux code had introduced new bugs — plus a third independent review pass of this very
fix batch, which caught two redaction regressions the batch itself introduced. Each fix is
covered by a regression test (suite now 90 tests).

**Security / correctness (High):**
- **`redact()` no longer conceals the attacks it exists to surface, and no longer leaks
the secrets it should mask.** The rule is now: a key that *names* a credential
(`password=`, `SSHPASS=`, `_auth=`, `_authToken=`) has its value redacted unconditionally
— including values that begin with `/` (base64 tokens), `$` (crypt/shadow hashes), or `~`;
a key that merely *contains* a directive name (`AuthorizedKeysFile`, `AuthorizedKeysCommandUser`)
keeps its value visible — including the default *relative-path* form `.ssh/authorized_keys`
— so a malicious sshd/sudoers change stays visible.
- **`redact()` is no longer quadratic.** A long attacker-plantable rc-file line stalled the
unattended `digest --notify` for minutes (8.4s @ 20KB → 0.5ms). Key-name runs are bounded.
- **Private-key / PEM bodies are masked on removed (`-`) diff lines too**, not only `+`.
- **Linux XDG autostart is fingerprinted by content hash**, so swapping `Exec=` in an
existing `.desktop` (same `Name=`) is now detected instead of being invisible.

**Robustness (Medium/Low):**
- Linux proxy detection reads *system* config (`/etc/environment`, `/etc/profile.d`) instead
of the caller's process environment — no more daily false ORANGE from timer-vs-shell.
- `clean()` neutralizes lone UTF-16 surrogates (a non-UTF-8 Linux filename no longer crashes
the report) and now **keeps TAB** (it can't forge a line; stripping it mangled config diffs).
- `redact()` also masks `SSHPASS=`/bare `pass=` values.
- `_write_private` uses `mkstemp` — a stale temp from a crashed run (or reused PID) can't
crash the next write.
- `since ignore` as the first-ever command now creates state at 0700 dir / 0600 file.
- `tilde()` collapses only a *leading* `$HOME`, not every occurrence.
- `/etc/ld.so.preload` (a rootkit hook) is treated as privilege-sensitive so a root/non-root
mismatch can't fabricate an add/remove alarm.
- `--json` now surfaces the "N unreadable snapshot(s) skipped" / baseline note.
- `install.sh` quotes the systemd `ExecStart` (repo paths with spaces) and no longer aborts
under `set -e` on a headless box with no user systemd session (writes units, reports how to
finish).

**From a fourth pass — a full-tool independent audit of the whole file, and a fifth end-to-end
integration pass through the real pipeline:**
- `~/.curlrc` credentials (`user = "name:password"`, `-u user:pass`) are now redacted — a real
leak in a tracked file that the URL-auth matcher missed, incl. on `+`/`-`-prefixed diff lines
(the integration test caught the prefixed form leaking where the bare-line unit test did not).
- Linux systemd/init.d units now fold their effective `ExecStart` (via `systemctl show`, so
drop-in overrides count) into the fingerprint — an `ExecStart` swap on an enabled unit was
previously invisible (the macOS plist path already content-hashed; now Linux does too).
- Sensitive-file monitoring extended to `/etc/sudoers.d/*`, `cron.{daily,hourly,weekly,monthly}`,
and the cron spool — the standard drop-in locations a real persistence entry would use.
- `since --since <absurd>` no longer crashes with an uncaught `OverflowError`.
- `clean()` also strips U+2028/U+2029 (line/paragraph separators); its comment now matches the
code (TAB is kept, by design).

## [0.4.1] — 2026-07-24

- Linux desktop notifications via `notify-send` (was macOS `osascript` only).
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ since ack # mark current state as normal — start fresh from her
since ignore 'listening:com.docker*' # stop alerting on known-noisy things
since ignore --list

since snapshot # capture only (what the daily job runs)
since snapshot # capture only, no diff output
since digest --notify # diff + desktop notification if 🟠 or worse
since list # list saved snapshots (labels shown)
since --json # machine-readable, with a max_level field
Expand Down Expand Up @@ -212,7 +212,7 @@ silent changes visible.

```sh
python3 -m pip install pytest
python3 -m pytest # 29 unit tests: diff/severity/time logic, injection-safety,
python3 -m pytest # 129 unit tests: diff/severity/time logic, injection-safety,
# privilege guard, corruption tolerance, secret redaction
```

Expand Down
22 changes: 22 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,25 @@ Please do **not** open a public issue for an unpatched vulnerability.
visibility is by design (see `since caps`).
- Snapshots contain sensitive host data and are stored `0600` in `~/.local/state/since`;
protecting that directory is the user's responsibility.

## Threat model — what `since` can and cannot detect

`since` compares the machine against **its own earlier snapshots**, which live in
`~/.local/state/since` (mode `0700`/`0600`) and are owned by the user who runs it. Two
consequences follow, and neither is fixable from inside the tool:

- **A process running as you can tamper with the baseline.** It can rewrite, delete or
pre-poison snapshots and the label file so that its own changes never show up as a diff
— the same privilege that lets it install persistence lets it erase the record of having
done so. `since` reports *changes to the system*; it does not attest to the integrity of
its own history. For a baseline an attacker on the box cannot reach, copy snapshots off
the machine (or keep them on append-only/read-only storage) and diff them there.
- **Helper binaries are resolved through `PATH`.** `lsof`, `ss`, `systemctl`, `codesign`,
`brew` and friends are invoked by name, so an interactive run with a hostile `PATH` (say
a fake `lsof` earlier in it) can filter the very output the report is built from. The
installed daily job runs under launchd/systemd with a minimal `PATH` and is not exposed
to a hostile shell environment.

Also by design: without `sudo` the listener/outbound view is partial and `/etc/sudoers` is
unreadable (`since caps` lists exactly what is and isn't covered), and snapshots taken at
different privilege levels are never compared for those categories.
19 changes: 13 additions & 6 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Description=since — daily change digest

[Service]
Type=oneshot
ExecStart=${PY} ${REPO_DIR}/since.py digest --notify
ExecStart="${PY}" "${REPO_DIR}/since.py" digest --notify
EOF
cat > "${SYSTEMD_DIR}/since.timer" <<EOF
[Unit]
Expand All @@ -109,11 +109,18 @@ Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now since.timer
echo " enabled systemd user timer: since.timer (output → journalctl --user -u since.service)"
echo " verify with: systemctl --user list-timers since.timer"
echo " headless box? run once: sudo loginctl enable-linger \"\$USER\" (so it fires without a login session)"
# Don't let a missing user D-Bus session (headless box, no `loginctl enable-linger`)
# abort the whole installer under `set -e`. Write the units regardless, and report
# honestly if we couldn't activate the timer so the user can finish the one step.
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"
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:"
echo " sudo loginctl enable-linger \"\$USER\""
echo " systemctl --user daemon-reload && systemctl --user enable --now since.timer"
fi
fi
else
echo " skipped daily job — run 'since snapshot' yourself, or re-run this installer."
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.1"
version = "0.4.3"
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
Loading
Loading