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
124 changes: 124 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,130 @@

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

## [0.4.4] — 2026-07-25

A security release fixing **16 issues found by reviewing v0.4.3 itself** — each reproduced by
execution before being fixed, and each pinned by a regression test that was *mutation-tested*
(revert the fix, confirm the test fails: **24/24 caught**). Suite 129 → **227**. Most of these sat
behind an architectural blind spot rather than inside any one function:

- Every previous audit round hardened the **snapshot** boundary; **diff-time enrichment had no
isolation and no input validation at all**. Collectors are individually failure-isolated;
`_enrich` was not, and `main` catches only `KeyboardInterrupt`.
- `redact()`'s "show" decisions exempted the **entire rest of the line** (its value group runs to
end-of-line), and its `=`/`:` branch redacted unconditionally.
- Undo hints were quoted for the **shell** but not for the **invoked program's own option parser**.

**CRITICAL — the remediation advice could execute attacker code:**
- **`osascript` re-parsed the login-item name as its own options.** The name is passed as an argv
parameter *and* shell-quoted, but neither stops `osascript` consuming a name that looks like an
option: a login item called `-e property zz : (do shell script "…")` became a second `-e` chunk
whose property initializer **ran at load** — so "delete this login item" executed the malware
author's command while the delete silently no-opped on an empty `argv` (verified with a benign
marker file). Every hint whose value can begin with `-` now ends option parsing with `--`.

**HIGH:**
- **One planted plist no longer kills the daily digest permanently.** A *valid* plist with a
non-dict root makes `plutil` emit `["x"]`; `.get` on that raised, and with no isolation the
digest died — **saving no snapshot**, so the plist stayed "added" and it failed identically every
day. Shapes are now validated, and an enrichment failure degrades **one finding** (which still
reports, tagged) instead of the whole run.
- **The daily job was blind to software changes, and the flood hid real installs.** launchd hands
an agent a `PATH` with neither `/opt/homebrew/bin` nor `~/.local/bin`, so `brew`/`npm`/`pip3`
silently vanished (`brew` 192 → 0) with **no error recorded**: 212 phantom findings, and a
genuine new package was pushed past the 40-line render cap and never displayed. Three-part fix —
collectors **declare their tools** (`need()`) so absence is recorded; the diff gained a
**capability guard** that skips a category unavailable *or answered by a different binary* in
either snapshot (snapshots now stamp tool identity, schema 4); and `install.sh` **pins the job's
`PATH`**, copying the installing shell's resolution order. A transient `brew list` timeout caused
the identical flood, so the guard — not the `PATH` — is the real fix.
- **Losing sight of a category is now a ranked finding, not a whisper.** Skipping an unusable
category is right (comparing fabricates mass add/remove) — but a passive `note:` would have let
an attacker buy *silence* by breaking a collector's tool, since neither the old phantom flood nor
a note ever reached the `--notify` threshold. A category that was visible in the baseline and
isn't now yields an ORANGE **"LOST VISIBILITY"** finding that ranks under "worth a look" and
fires the notification — and that covers a tool that merely **changed** as well as one that
vanished, because the pinned job `PATH` necessarily includes user-writable directories, so
planting `~/.local/bin/brew` swaps the tool identity *without* erroring and was the cheapest
suppression route of all. A first run, a legitimately absent tool, and the one-time pre-v0.4.4
stamp transition stay quiet notes — all verified. (Both found by reviewing the capability guard
added earlier in this same release.)
- **`difflib` was quadratic on a planted rc file** (~100 distinct repeated lines defeat its
autojunk filter): a 0.74 MB `~/.zshrc` cost 32s of a real digest run. `MAX_READ` bounded the
read; nothing bounded the diff. Above 20k lines / 1 MB the change is reported *with a content
hash* instead of a line diff — same finding, same severity, 30.5s → 0.01s.
- **`redact()` concealed path-valued env hijacks entirely**: `SSH_AUTH_SOCK`, `SSH_ASKPASS`,
`SUDO_ASKPASS`, `GIT_ASKPASS`, `PGPASSFILE` — each a known credential-theft technique in a
tracked rc file, where the path *is* the finding — rendered as `«redacted»`.
- **The v0.4.3 sudoers `PASSWD:` carve-out was half-done**: a tag chain (`PASSWD:NOEXEC:`) or a
comma list (`PASSWD: ALL, !/usr/bin/su`) still had the granted command list redacted away.
- **Three cleartext credential leaks** in files the tool diffs: `sshpass -p 'secret'` (the spaced
form; the attached form was already masked), `https://<token>@github.com/` in `.gitconfig`, and
`MYSQL_PWD=` (the `PWD` spelling was not a keyword).

**MEDIUM / LOW:**
- A planted FIFO `.app` made `codesign` block for its full 10s timeout *per item*; trust checks are
now shape-gated (12 planted FIFOs: 120s → 0.00s).
- The three state-dir reads still using raw `read_text` (`safe_load`, `load_labels`,
`load_ignores`) hung forever on a FIFO planted in `~/.local/state/since`.
- A "show" decision no longer exempts later secrets on the same line (`AuthorizedKeysCommand
/usr/bin/fk --api-key=…` printed the key), and the `NOPASSWD` carve-out is gated on the exact
uppercase tag — `export NOPASSWD_TOKEN=…` leaked through the old substring test.
- `PasswordAuthentication=yes` / `AuthorizedKeysFile=/tmp/evil/keys` — the valid `Key=value`
spelling — are shown again instead of redacted.
- Every cask's undo hint named a nonexistent formula (`brew uninstall 'foo (cask)'`) → now
`brew uninstall --cask foo`. `/Library/LaunchAgents` gets `sudo rm` (that directory is
root-owned, so the unprivileged `rm` could never succeed). A wrong-type collector value in a
baseline no longer crashes the diff.
- Blobs are stored capped at 256 KB plus a hash of the full content: 13 tracked files at the 8 MB
read cap meant ~109 MB per snapshot and ~9.6 GB across `KEEP_SNAPSHOTS=90` → ~293 MB, with
changes past the cap still detected.

**Second review round — 7 more issues, all in this very fix batch.** Re-attacking v0.4.4 before
publishing it found that the fixes above had introduced their own problems. Fixed, each with a
mutation-tested regression test (40/40 mutations caught cumulatively):
- **CRITICAL: the new `redact()` tail rescan was an unprivileged kill switch.** It recursed once per
credential-ish key on a line, so a 2.5 KB comment of repeated `_pwd ` — under `_REDACT_MAX`, so
the cap did not help — raised `RecursionError`. Nothing catches it, the digest died before saving
a snapshot, the planted line stayed "added", and **every later run died identically**: one line in
`~/.zshrc` disabled the tool permanently. The rescan depth is now bounded and fails *safe*
(redact), and the cost is back to ~1.4× v0.4.3 instead of quadratic.
- **A skipped category no longer becomes its own baseline.** The capability guard skipped the blind
day — but that snapshot still became tomorrow's baseline, so a package installed during the blind
window was never reported by *any* run while the report said "Nothing changed. 🎉". A skipped
category is now diffed against the newest earlier snapshot that could see it with the same tool,
and the all-clear line no longer claims nothing changed when something could not be compared.
- **`need()` proved a tool RESOLVES, never that it RAN**, so the `brew list` timeout named as the
guard's own motivation still produced the phantom flood. Collectors now use a checked runner that
records a timeout or a failing exit as unavailability (tolerating `npm`'s non-zero-with-output).
- **The guard crashed on a wrong-typed `tools`/`errors` field** in a baseline — the same class this
release had already fixed twice — killing the digest with no snapshot saved, permanently.
- **The storage cap silently disabled RED escalation.** A payload appended past `BLOB_MAX` (or past
the diff cap) never reached the diff text, so a `curl | sh` line fell to ORANGE with no `why`.
Malicious patterns are now scanned over the *full* content at snapshot time and diffed as flags.
- **The new full-content scan re-opened a quadratic DoS** (found by measuring my own round-2 fix
rather than trusting it): two malicious-pattern regexes used unbounded `.*`, which is quadratic
in the number of trigger tokens on one line — a planted line of repeated `base64 -d ` cost 55s at
375 KB and hours at `MAX_READ`, at **snapshot** time, before anything is saved. The runs are now
bounded and the scan is line-wise with a per-line cap: flat ~3 ms regardless of token count,
detection unchanged. This was latent in the pre-v0.4.4 diff-text path too.
- **`plutil` was handed unbounded input** at diff time (a 500 MB plist measured 2.27 GB RSS); the
plist is size-gated now, like every collector read.
- **A malicious LaunchAgent was labelled "signature: Apple-signed".** `ProgramArguments =
["/bin/sh","-c","curl …|sh"]` resolves to `/bin/sh`, which genuinely is — so the report reassured
the user about the payload. The argv is now scanned for malicious patterns (which outrank any
signature on the interpreter), and an overwritten *existing* plist — the classic hijack — is
trust-checked and escalated instead of being a quiet YELLOW hash change.

**Docs:** `SECURITY.md`'s `PATH` paragraph was **wrong by omission** — it presented the daily job's
minimal `PATH` purely as a safety property when it was also the cause of the blindness above. It
now states the trade plainly: the pinned `PATH` includes user-writable directories, and `since`
does not attest to the integrity of the tools it asks.

**First run after upgrading:** snapshots taken before this version carry no tool-identity stamp, so
`brew`/`npm`/`pip` comparisons are skipped **once**, with a note, until a new baseline exists — the
same fail-closed transition the privilege stamp used.

## [0.4.3] — 2026-07-25

Fixes for a **third** independent (Kimi) adversarial audit, which re-verified every v0.4.2
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 # 129 unit tests: diff/severity/time logic, injection-safety,
python3 -m pytest # 227 unit tests: diff/severity/time logic, injection-safety,
# privilege guard, corruption tolerance, secret redaction
```

Expand Down
15 changes: 11 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,17 @@ consequences follow, and neither is fixable from inside the tool:
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.
`brew` and friends are invoked by name, so a run with a hostile `PATH` (say a fake `lsof`
earlier in it) can filter the very output the report is built from. `install.sh` **pins**
the daily job's `PATH` at install time, copying your shell's resolution order, because it
must: launchd hands an agent a default `PATH` containing neither `/opt/homebrew/bin` nor
`~/.local/bin`, so an unpinned job could not see `brew`/`npm`/`pip3` at all. That pinning
is a deliberate trade: the job's `PATH` therefore includes user-writable directories (a
Homebrew prefix, `~/.local/bin`), so a process running as you could substitute a helper
binary there — the same privilege that already lets it rewrite your baselines. `since`
detects changes to the system; it does not attest to the integrity of the tools it asks.
If a collector's tool is missing, or a *different* binary answers than last time, the
affected category is **skipped with a note** rather than reported as mass removals.

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
Expand Down
27 changes: 27 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,30 @@ esac
read -r -p "Install daily digest job (09:00 each day, notifies if worth a look)? [y/N] " ans
if [[ "${ans:-}" =~ ^[Yy]$ ]]; then
PY="$(command -v python3)"
# The job's PATH is NOT your shell's: launchd hands agents a default PATH with no
# /opt/homebrew/bin and no ~/.local/bin, and a systemd --user unit gets a minimal one
# too. `since` shells out to brew/npm/pip3/mas, so unpinned those collectors silently
# returned nothing — the digest reported every package as REMOVED (a flood that also
# pushes a genuine new install past the render cap). Pin the dirs where those tools
# actually live, resolved from the environment doing the install.
# Built from the INSTALLING SHELL's PATH, in its order, then the standard dirs. Order
# matters as much as membership: prepending /opt/homebrew ahead of ~/.local/bin made the
# job resolve a different `npm` than the shell (5 packages vs 1) — a permanent phantom
# diff. Only absolute entries are kept, so a relative '.' on PATH can't ride along.
# NOTE: IFS splitting, not ${PATH//:/...} — macOS ships bash 3.2, where the ANSI-C
# replacement form silently produces nothing and this would quietly rebuild the very
# blindness it exists to prevent.
JOB_PATH=""
_add_dir() {
case ":${JOB_PATH}:" in *":$1:"*) return;; esac
[ -d "$1" ] || return
case "$1" in /*) JOB_PATH="${JOB_PATH:+${JOB_PATH}:}$1";; esac
}
_old_ifs="$IFS"; IFS=":"
for d in $PATH; do _add_dir "$d"; done
IFS="$_old_ifs"
for d in /usr/local/bin /usr/bin /bin /usr/sbin /sbin; do _add_dir "$d"; done
echo " daily job PATH: ${JOB_PATH}"
if [[ "$OS" == "Darwin" ]]; then
mkdir -p "${HOME}/Library/LaunchAgents"
cat > "${PLIST}" <<EOF
Expand All @@ -76,6 +100,8 @@ if [[ "${ans:-}" =~ ^[Yy]$ ]]; then
<string>digest</string>
<string>--notify</string>
</array>
<key>EnvironmentVariables</key>
<dict><key>PATH</key><string>${JOB_PATH}</string></dict>
<key>StartCalendarInterval</key>
<dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<key>RunAtLoad</key><false/>
Expand All @@ -96,6 +122,7 @@ Description=since — daily change digest

[Service]
Type=oneshot
Environment=PATH=${JOB_PATH}
ExecStart="${PY}" "${REPO_DIR}/since.py" digest --notify
EOF
cat > "${SYSTEMD_DIR}/since.timer" <<EOF
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.3"
version = "0.4.4"
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