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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ followed by a re-scan. The server only accepts names its last scan found.
Updates wait until a release is that old, since hijacked releases are usually caught and pulled within days.
uv uses `--exclude-newer` and npm `--before`, which also cover their dependencies; brew/go/cargo skip packages
still cooling (cargo is pinned to the scanned version). Release dates come from PyPI, the npm registry, the
Go proxy and crates.io; Homebrew has none, so its clock starts when a scan first sees the version.
Go proxy and crates.io; Homebrew has none, so formulae use the build time of the version's first bottle on
ghcr.io, and casks (no bottles) start the clock when a scan first sees the version. pip packages show no
cooldown, since pkgdash doesn't update them.
</details>

<details>
Expand All @@ -145,7 +147,8 @@ covered (OSV has no Homebrew malware data).
The malware check also asks Socket's API. Its blocking verdicts (malware, typosquats, protestware…) block
updates the same way, and each pending version gets a note: `socket ✓`, `not analysed yet`, or what it starts
doing that the installed version didn't (`new: install script · network access`). Add the token on the setup
page: it's tested against Socket, then stored in the login Keychain (item `pkgdash-socket-token`) through
page; it only needs the `packages:list` scope (pkgdash calls `organizations`, `quota` and the batch PURL
lookup, read-only). It's tested against Socket, then stored in the login Keychain (item `pkgdash-socket-token`) through
`security -i` on stdin, so it never appears on a command line, and it's never written to pkgdash's own
files. Answers are cached in state.json (installed versions for 3 days, pending ones for a scan) because the
free plan's quota is small (500, 100 per lookup); the settings page shows what's left.
Expand Down
18 changes: 11 additions & 7 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,12 @@
const more = queued.length ? ` · +${queued.length} queued` : "";
return `${bar}<div class="step"><b>${VERB[run.action]}${run.pkg ? " " + esc(run.pkg) : ""}</b> ${secs(el)}${eta}${more}${run.step ? " — " + esc(run.step) : ""}</div>`;
}
// whether pkgdash installs updates for this package's manager (pip's are left to Homebrew / macOS)
const canUpdate = p => { const m = mgr(p.mid); return !!m && m.actions.includes("update") && !m.hold; };
// seconds until a package's new version is past its cooldown period (matches cooldown_left in server.py)
const coolLeft = p => {
const days = Number(S.settings.cooldown_days) || 0;
return days > 0 && p.outdated && p.latest && p.released ? Math.max(0, p.released + days * 86400 - S.now) : 0;
return days > 0 && canUpdate(p) && p.outdated && p.latest && p.released ? Math.max(0, p.released + days * 86400 - S.now) : 0;
};
const leftText = s => s >= 86400 ? `${Math.ceil(s / 86400)} day${Math.ceil(s / 86400) === 1 ? "" : "s"}` : `${Math.max(1, Math.ceil(s / 3600))} hour${Math.ceil(s / 3600) <= 1 ? "" : "s"}`;
// matches flag_text in server.py
Expand Down Expand Up @@ -541,6 +543,8 @@
const t = S.totals;
const lastScan = Math.max(0, ...S.managers.map(m => m.scanned_at || 0));
const cooling = S.managers.reduce((a, m) => a + (m.cooling || 0), 0), blocked = S.managers.reduce((a, m) => a + (m.blocked || 0), 0);
// outdated in managers pkgdash doesn't update (pip): not "ready", they're updated by hand
const manual = S.managers.filter(m => !m.separate && (m.hold || !m.actions.includes("update"))).reduce((a, m) => a + (m.outdated || 0), 0);
$("#view").innerHTML = `
<div class="page-head">
<div><h1>SYSTEM_OVERVIEW</h1><div class="sub">Status: <b>ONLINE</b> | Uptime: <b>${uptime(S.uptime)}</b> | Last scan: <b>${ago(lastScan || null)}</b> | Next: scan <b>${until(S.next.scan)}</b> · audit <b>${until(S.next.audit)}</b></div></div>
Expand All @@ -552,7 +556,7 @@
<div class="grid stats">
${statCard("", "PACKAGES_TRACKED", "box", fmt(t.packages), `<span class="tag">${icon("plus").replace("<svg", '<svg width="12" height="12"')} +${t.new7}</span>`, "new packages / 7d", "#/packages")}
${statCard(t.high ? "red" : t.vulns ? "amber" : "", "KNOWN_VULNS", "shield", fmt(t.vulns), `<span class="tag ${t.high ? "red" : "muted"}">${t.high} high/crit</span>`, "across all audits", "#/vulns")}
${statCard("amber", "OUTDATED", "up", fmt(t.outdated), `<span class="tag muted">${cooling} in cooldown</span>${blocked ? ` <span class="tag red">${blocked} blocked</span>` : ""}`, `${Math.max(0, t.outdated - cooling - blocked)} ready to update`, "#/outdated")}
${statCard("amber", "OUTDATED", "up", fmt(t.outdated), `<span class="tag muted">${cooling} in cooldown</span>${blocked ? ` <span class="tag red">${blocked} blocked</span>` : ""}`, `${Math.max(0, t.outdated - cooling - blocked - manual)} ready to update${manual ? ` · ${manual} by hand` : ""}`, "#/outdated")}
${statCard("blue", "JOBS_24H", "pulse", fmt(t.jobs24), `<span class="tag ${t.failed24 ? "red" : "blue"}">${t.failed24} failed</span>`, "scan · audit · update", "#/jobs")}
</div>
<div class="grid">${S.managers.filter(m => m.available).map(mgrCard).join("")}</div>
Expand Down Expand Up @@ -654,8 +658,7 @@
/* ---------- package tables (manager pages, PACKAGES, OUTDATED) ---------- */
// rows are inventory packages with their manager id added as `mid`
function updBtn(p) {
const m = mgr(p.mid);
if (!m || !m.actions.includes("update") || m.hold) return "";
if (!canUpdate(p)) return "";
if (pkgBusy(p.mid, p)) return ' <button class="btn sm busy" disabled>&gt; updating…</button>';
const attrs = `data-upd="${esc(p.mid)}" data-kind="${esc(p.kind)}" data-pkg="${esc(p.name)}"`;
return coolLeft(p) ? ` <button class="btn sm" ${attrs}>&gt; update anyway</button>` : ` <button class="btn sm warn" ${attrs}>&gt; update</button>`;
Expand Down Expand Up @@ -736,8 +739,9 @@
function viewOutdated() {
const rows = allPkgs().filter(p => p.outdated)
.sort((a, b) => (!!a.blocked - !!b.blocked) || (coolLeft(a) - coolLeft(b)) || a.mid.localeCompare(b.mid) || a.name.localeCompare(b.name));
const ready = rows.filter(p => !p.blocked && !coolLeft(p)).length, cooling = rows.filter(p => !p.blocked && coolLeft(p)).length;
$("#osub").innerHTML = `<b>${ready}</b> ready to update · <b>${cooling}</b> in cooldown period${rows.length - ready - cooling ? ` · <b>${rows.length - ready - cooling}</b> blocked` : ""} · cooldown is ${Number(S.settings.cooldown_days) || 0} days (<a class="link" href="#/config">change</a>)`;
const blocked = rows.filter(p => p.blocked).length, manual = rows.filter(p => !p.blocked && !canUpdate(p)).length;
const cooling = rows.filter(p => !p.blocked && coolLeft(p)).length, ready = rows.length - blocked - manual - cooling;
$("#osub").innerHTML = `<b>${ready}</b> ready to update · <b>${cooling}</b> in cooldown period${blocked ? ` · <b>${blocked}</b> blocked` : ""}${manual ? ` · <b>${manual}</b> to update by hand` : ""} · cooldown is ${Number(S.settings.cooldown_days) || 0} days (<a class="link" href="#/config">change</a>)`;
$("#obody").innerHTML = rows.length ? pkgTable(rows, true) : `<div class="empty">everything is up to date</div>`;
}

Expand Down Expand Up @@ -852,7 +856,7 @@

function setupSocket() {
return `<div class="form">
<div class="row col"><div><div>API token</div><div class="d">free at <a class="link" href="https://socket.dev" target="_blank" rel="noopener">socket.dev</a> → Settings → API Tokens. Kept in your login Keychain (item <code>pkgdash-socket-token</code>), never in pkgdash's own files</div></div>
<div class="row col"><div><div>API token</div><div class="d">free at <a class="link" href="https://socket.dev" target="_blank" rel="noopener">socket.dev</a> → Settings → API Tokens. It only needs the <code>packages:list</code> scope (the test below can't check scopes: a missing one shows up as an HTTP 403 in the next scan's log). Kept in your login Keychain (item <code>pkgdash-socket-token</code>), never in pkgdash's own files</div></div>
<div class="pathrow"><input type="password" id="sockTok" placeholder="sktsec_…" spellcheck="false" autocomplete="off"><button class="btn sm" id="sockSave">&gt; save_&amp;_test</button></div>
<div class="d" id="sockStatus">checking the Keychain…</div></div>
${settingRows(["socket_check"])}
Expand Down
49 changes: 42 additions & 7 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,54 @@ DIR=$(cd "$(dirname "$0")" && pwd)
AGENTS="$HOME/Library/LaunchAgents"
STATE="$HOME/.local/share/pkgdash"
APP="$HOME/Applications/pkgdash.app"
BIN="$APP/Contents/MacOS/pkgdash"
GUI="gui/$(id -u)"

step() { printf '==> %s\n' "$*"; }
fail() { printf 'pkgdash: %s\n' "$*" >&2; exit 1; }

# The menu bar app is only rebuilt when its source (or this script) changes: it's ad-hoc signed, so every
# rebuild looks like a new app to macOS and it would ask for access to the projects folder again.
NEED_BUILD=0
if [ ! -x "$BIN" ] || [ "$DIR/menubar/PkgdashMenu.swift" -nt "$BIN" ] || [ "$0" -nt "$BIN" ]; then
NEED_BUILD=1
fi

# check what's needed before stopping anything, so a failed install leaves a running pkgdash alone
if [ "${1:-}" != "uninstall" ]; then
[ "$(uname -m)" = arm64 ] || fail "needs a Mac with Apple Silicon (this one is $(uname -m))"
if ! xcode-select -p >/dev/null 2>&1 || ! /usr/bin/python3 -c "" >/dev/null 2>&1; then
fail "needs the Xcode Command Line Tools for /usr/bin/python3 and swiftc: run xcode-select --install, then try again"
fi
if [ "$NEED_BUILD" = 1 ] && ! xcrun --find swiftc >/dev/null 2>&1; then
fail "can't find swiftc to build the menu bar app: run xcode-select --install, then try again"
fi
fi

step "Stopping the running service and menu bar app (if any)"
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
launchctl bootout "$GUI/$MENU_LABEL" 2>/dev/null || true
# bootout returns before the jobs are gone, and loading them again too soon fails ("Bootstrap failed: 5")
waited=0
for _ in $(seq 30); do
{ launchctl print "$GUI/$LABEL" || launchctl print "$GUI/$MENU_LABEL"; } >/dev/null 2>&1 || break
printf '.'
waited=1
sleep 1
done
if [ "$waited" = 1 ]; then echo; fi

if [ "${1:-}" = "uninstall" ]; then
step "Removing the LaunchAgents and $APP"
rm -f "$AGENTS/$LABEL.plist" "$AGENTS/$MENU_LABEL.plist"
rm -rf "$APP"
echo "pkgdash removed"
echo "pkgdash removed (state in $STATE is kept)"
exit 0
fi

# --- dashboard service -------------------------------------------------------
# launchd agents can't read protected folders like ~/Documents (TCC), so run from a copy in the state dir
step "Installing the dashboard service into $STATE/app"
mkdir -p "$STATE/app"
cp "$DIR/server.py" "$DIR/index.html" "$STATE/app/"
cat > "$AGENTS/$LABEL.plist" <<EOF
Expand All @@ -51,16 +80,15 @@ cat > "$AGENTS/$LABEL.plist" <<EOF
</dict>
</plist>
EOF
step "Starting the dashboard service"
launchctl bootstrap "$GUI" "$AGENTS/$LABEL.plist"

# --- menu bar icon -----------------------------------------------------------
# Only rebuilt when its source (or this script) changes: it's ad-hoc signed, so every rebuild
# looks like a new app to macOS and it would ask for access to the projects folder again.
BIN="$APP/Contents/MacOS/pkgdash"
if [ ! -x "$BIN" ] || [ "$DIR/menubar/PkgdashMenu.swift" -nt "$BIN" ] || [ "$0" -nt "$BIN" ]; then
if [ "$NEED_BUILD" = 1 ]; then
step "Building the menu bar app (swiftc, can take a minute)"
mkdir -p "$APP/Contents/MacOS"
swiftc -O -target arm64-apple-macos13 -framework AppKit \
-o "$BIN" "$DIR/menubar/PkgdashMenu.swift"
-o "$BIN" "$DIR/menubar/PkgdashMenu.swift" || fail "swiftc couldn't build menubar/PkgdashMenu.swift (see above)"
cat > "$APP/Contents/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
Expand All @@ -82,7 +110,13 @@ cat > "$APP/Contents/Info.plist" <<EOF
EOF
# hardened runtime: macOS ignores DYLD_* variables (e.g. planted in the LaunchAgent), so no other code
# can be loaded into the app that holds the projects-folder permission
codesign --force --options runtime --sign - "$APP" >/dev/null 2>&1
step "Signing the menu bar app (ad-hoc, hardened runtime)"
if ! out=$(codesign --force --options runtime --sign - "$APP" 2>&1); then
rm -f "$BIN" # so the next run rebuilds and signs it again
fail "codesign failed: $out"
fi
else
step "Menu bar app unchanged, not rebuilding it"
fi
# KeepAlive only on crash, so "Quit Menu Icon" stays quit until next login
cat > "$AGENTS/$MENU_LABEL.plist" <<EOF
Expand All @@ -99,6 +133,7 @@ cat > "$AGENTS/$MENU_LABEL.plist" <<EOF
</dict>
</plist>
EOF
step "Starting the menu bar app"
launchctl bootstrap "$GUI" "$AGENTS/$MENU_LABEL.plist"

echo "pkgdash running → http://127.0.0.1:7878 (menu bar icon: left-click opens, right-click for menu)"
52 changes: 44 additions & 8 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,17 +167,24 @@ def loads(text, default):
return default


def http_json(url, body=None, timeout=30):
def http_json(url, body=None, timeout=30, headers=None):
if not url.startswith("https://"): # registries and OSV only: never file: or plain http
raise ValueError("not an https URL: " + url[:80])
req = urllib.request.Request( # noqa: S310 - https enforced above
url, data=json.dumps(body).encode() if body is not None else None,
headers={"User-Agent": "pkgdash/1.0 (local dashboard)",
"Content-Type": "application/json"})
"Content-Type": "application/json", **(headers or {})})
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
return json.loads(r.read())


def pypi_released(name, version):
"""Upload time of a PyPI release (its first file), if PyPI has it."""
data = http_json("https://pypi.org/pypi/%s/%s/json" % (quote(name), quote(version)))
times = [t for t in (parse_iso(u.get("upload_time_iso_8601")) for u in data.get("urls", [])) if t]
return min(times) if times else None


# ---------------------------------------------------------------- store

class Store:
Expand Down Expand Up @@ -798,9 +805,7 @@ def dep_tree(self, job):
return [(*l.split("==", 1), tool) for tool, reqs in self.freeze(job).items() for l in reqs]

def released(self, job, p):
data = http_json("https://pypi.org/pypi/%s/%s/json" % (p["name"], p["latest"]))
times = [t for t in (parse_iso(u.get("upload_time_iso_8601")) for u in data.get("urls", [])) if t]
return min(times) if times else None
return pypi_released(p["name"], p["latest"])

def receipt(self, tool):
"""Requirements from uv-receipt.toml (no tomllib on 3.9): [{name, specifier|directory|git|...}]."""
Expand Down Expand Up @@ -1018,6 +1023,7 @@ def update(self, job, only=None):
class Brew(Manager):
id, label, icon, color = "brew", "homebrew", "beer", "amber"
desc = "Homebrew formulae and casks (vulns via brew-vulns / OSV)"
bottle_repo = {} # formula name → its ghcr.io repo, for release dates (filled by each scan)

def available(self):
return bool(which("brew"))
Expand All @@ -1035,6 +1041,7 @@ def scan(self, job):
o = self.outdated(job)
lf = {x["name"]: x["current_version"] for x in o.get("formulae", [])}
lc = {x["name"]: x["current_version"] for x in o.get("casks", [])}
self.bottle_repos(job, sorted(lf))
res = []
for line in f.splitlines():
parts = line.split()
Expand All @@ -1047,6 +1054,31 @@ def scan(self, job):
res.append(pkg(parts[0], parts[-1], lc.get(parts[0]), "cask"))
return res

def bottle_repos(self, job, names):
"""Where each outdated homebrew/core formula's bottles live on ghcr.io ("homebrew/core/python/3.13")."""
self.bottle_repo = {}
if not names:
return
_, out = job.run(["brew", "info", "--json=v2", "--formula"] + names, quiet=True, timeout=300)
for x in loads(out, {}).get("formulae", []):
files = ((x.get("bottle") or {}).get("stable") or {}).get("files") or {}
url = next(iter(files.values()), {}).get("url", "")
m = re.match(r"https://ghcr\.io/v2/(homebrew/core/[\w.+@/-]+)/blobs/", url)
if m:
self.bottle_repo[x["name"]] = m.group(1)

def released(self, job, p):
"""Homebrew has no release dates, but a core bottle's manifest says when it was built. The plain version
tag is the first build (rebuilds are tagged -1, -2, ...), which comes soon after upstream's release.
Casks and tap formulae have none, so their cooldown counts from when a scan first saw them."""
repo = self.bottle_repo.get(p["name"]) if p["kind"] == "formula" else None
if not repo:
return None
# "QQ==" is ghcr.io's anonymous token, enough for public images like Homebrew's bottles
data = http_json("https://ghcr.io/v2/%s/manifests/%s" % (repo, quote(p["latest"])),
headers={"Authorization": "Bearer QQ==", "Accept": "application/vnd.oci.image.index.v1+json"})
return parse_iso((data.get("annotations") or {}).get("org.opencontainers.image.created"))

def audit(self, job):
_, out = job.run(["brew", "vulns", "--json", "-m", "0"], quiet=True, timeout=1800)
data = loads(out, None)
Expand Down Expand Up @@ -1080,8 +1112,8 @@ def update(self, job, only=None):
raise RuntimeError("%s not updated: %s" % (only[1], gate[only]))
return self.update_one(job, *only)
job.stream(["brew", "update"], timeout=900)
# Homebrew has no release dates, so the cooldown counts from when a scan first saw each
# version. Only upgrade what the scan saw (a version that appeared since then waits).
# the cooldown only knows about versions a scan has dated (bottle build time, or when the scan
# first saw it), so only upgrade what the scan saw: a version that appeared since then waits.
inv, o = inv_pkgs(self.id), self.outdated(job)

def ready(kind, items):
Expand Down Expand Up @@ -1144,6 +1176,9 @@ def pythons(self):
def available(self):
return bool(self.pythons())

def released(self, job, p):
return pypi_released(p["name"], p["latest"])

def scan(self, job):
res = []
for py, kind, extra in self.pythons():
Expand Down Expand Up @@ -2007,9 +2042,10 @@ def state_payload():
ign = m.ignored()
fs = [f for f in fs if f["context"] not in ign]
mal = [h for h in mal if h["context"] not in ign]
updatable = "update" in m.actions and not m.hold # pip: shown, never updated, so no cooldown either
d.update(malware=[h for h in mal if h["where"] == "installed"], osv=bool(m.osv_eco),
malware_checked_at=(store["malware"].get(m.id) or {}).get("checked_at"),
cooling=sum(cooldown_left(p) > 0 and not p.get("blocked") for p in pk),
cooling=sum(updatable and cooldown_left(p) > 0 and not p.get("blocked") for p in pk),
blocked=sum(bool(p.get("blocked")) for p in pk))
totals["malware"] += len(d["malware"])
d.update(count=len(pk), outdated=sum(p["outdated"] for p in pk), vulns=len(fs),
Expand Down
Loading