From 617f9a7bbab0eca0cbd14e278518ee88f898eadf Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Sat, 25 Jul 2026 11:44:20 +0700 Subject: [PATCH 1/2] fix(privacy): stop leaking repo names to GitHub, and check the claim in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #100 and #101. The Projects panel fetched `api.github.com/repos/${project_key}` from the user's browser, and loaded `github.com/${owner}.png` as an in two components. Each request put the name of a repository the user has checked out — private ones included — into a URL sent to a third party. The README said the opposite. It claimed avatars were "fetched server-side so your browser doesn't contact them directly", and listed api.github.com only under provider quota. Both were written by me earlier today, from an audit that grepped `src/` and never `dashboard/src/`. The avatar proxy that claim credits turns out to have no caller in the dashboard at all. Removed the enrichment rather than proxying it. Star counts and owner avatars are decoration on a panel about your own spending, and routing them through the local server would still send the repo name, just from a different process. The durable half is scripts/validate-outbound.cjs, wired into ci:local: - Scans host literals ANYWHERE in src/ and dashboard/src/, not inside fetch( calls. The leak that prompted this was an ; a call-site scan reproduces the original blind spot in code. - Fails on a host the code can reach that outbound-hosts.json does not declare, on a declaration no code references, and on a user-data host missing from the README table. - Enforces seen_in, so a declared host may only be reached from the files that declare it. Without that, re-adding the exact #100 call would pass, because api.github.com is legitimately declared for the header star. - Rejects a fetch() whose target is built at runtime in a file that also names an external host — the hole a literal scan cannot see. It earned its keep on first run: it found `skills.sh`, a ninth host that two rounds of hand-searching had missed, and then that six of my hand-written seen_in lists were wrong. Those are now generated from the scan. The README table is rewritten from the inventory and names 21 hosts where it previously named 12. Also folded in, and called out rather than buried: four links still pointed at the upstream fork, including one `init.js` prints to every user on first run. Same defect class as the CONTRIBUTING clone URL fixed in #95. Two guards on the removal itself: the component tests now assert no remote image renders, and a source-level test asserts no absolute URL appears in ProjectUsagePanel — which fails if someone adds an or rather than a fetch. --- README.md | 26 +- dashboard/src/components/LocalOnlyNotice.jsx | 2 +- dashboard/src/pages/WidgetsPage.jsx | 2 +- .../src/ui/components/HeaderGithubStar.jsx | 2 +- .../ui/dashboard/components/DataDetails.jsx | 17 +- .../components/ProjectUsagePanel.jsx | 99 +------ .../components/__tests__/DataDetails.test.jsx | 9 +- .../__tests__/ProjectUsagePanel.test.jsx | 4 +- outbound-hosts.json | 245 ++++++++++++++++++ package.json | 5 +- scripts/validate-outbound.cjs | 219 ++++++++++++++++ src/commands/init.js | 2 +- test/dashboard-layout-adjustments.test.js | 18 +- test/outbound-inventory.test.js | 177 +++++++++++++ 14 files changed, 698 insertions(+), 129 deletions(-) create mode 100644 outbound-hosts.json create mode 100644 scripts/validate-outbound.cjs create mode 100644 test/outbound-inventory.test.js diff --git a/README.md b/README.md index 9d9ca94e..ecd0ccac 100644 --- a/README.md +++ b/README.md @@ -144,16 +144,22 @@ No account, no upload of your usage, and no server to sign in to. | **Auditable in one command** | You don't have to take our word for it — the store is an append-only text file you can open yourself: `cat ~/.tokentracker/tracker/queue.jsonl`. It's numbers and timestamps. | | **No telemetry** | No analytics, no crash reporting, no phone-home, no account. | -**Outbound calls, on your behalf only.** TokenTracker is local-first, not network-free. It talks to the internet in exactly these cases, and none of them carry your usage data: - -| When | Where | Why | -|---|---|---| -| Pricing refresh (daily) | `raw.githubusercontent.com` | Downloads the public [LiteLLM](https://github.com/BerriAI/litellm) price list. Anonymous — no credentials, nothing sent. Works offline from a bundled snapshot. | -| Quota chips + Limits page | `api.anthropic.com`, `chatgpt.com`, `cursor.com`, `cloudcode-pa.googleapis.com`, `api.kimi.com`, `api.z.ai`, `api.github.com` | Asks *your* provider about *your* plan limits, using credentials already on your machine. Only for providers you actually use. | -| Token refresh | `auth.openai.com`, `oauth2.googleapis.com`, `auth.kimi.com` | Renews those same provider credentials when they expire. | -| Profile avatars | Allowlisted avatar CDNs | Fetched server-side so your browser doesn't contact them directly. | -| IP check page | `ip.net.coffee` | Only if you open that page. | -| `npx` startup | npm registry | How `npx` works — it downloads the package. A global install avoids it. | +**Outbound calls.** TokenTracker is local-first, not network-free. It reaches these hosts and no others. None of them carry your usage data — but some do reveal that *you* are asking, so they are listed with who makes the call. + +This table is checked in CI against [`outbound-hosts.json`](outbound-hosts.json): a host the code can reach but the file does not declare fails the build, and so does a declared host missing from this table. It is not maintained by memory. + +| When | Host | From | Why | +|---|---|---|---| +| Pricing refresh (daily) | `raw.githubusercontent.com` | server | The public [LiteLLM](https://github.com/BerriAI/litellm) price list. Anonymous — no credentials, nothing sent. Works offline from a bundled snapshot. | +| Quota chips + Limits page | `api.anthropic.com`, `chatgpt.com`, `api.openai.com`, `cursor.com`, `www.cursor.com`, `cloudcode-pa.googleapis.com`, `api.kimi.com`, `api.z.ai`, `api.github.com` | server | Asks *your* provider about *your* plan limits, using credentials already on your machine. Only for providers you actually use. | +| Token refresh | `auth.openai.com`, `oauth2.googleapis.com`, `auth.kimi.com` | server | Renews those same provider credentials when they expire. | +| Skills tab | `skills.sh`, `api.github.com`, `github.com` | server | Searches the public skills directory and reads public repository metadata. Sends your search terms, nothing else. Only when you open that tab. | +| Currency conversion | `open.er-api.com` | **browser** | Public USD exchange rates. Anonymous. Only when you pick a non-USD currency. | +| Star count in the header | `api.github.com` | **browser** | The star count for this project's own repository — a fixed public URL that says nothing about you. | +| IP check page | `ip.net.coffee`, `1.1.1.1`, `claude.ai`, `www.anthropic.com` | server + **browser** | Only if you open that page, whose entire purpose is showing you your own IP and whether Anthropic is reachable. | +| `npx` startup | `registry.npmjs.org` | server | How `npx` works — it downloads the package. A global install avoids it. | + +**What is deliberately absent:** the Projects panel does not fetch repository avatars or star counts. Doing so would have put the name of a repository you have checked out — a private one included — into a URL sent to GitHub from your browser. It did, until [#100](https://github.com/pitimon/TokenTracker/issues/100). Project rows now render a local icon. --- diff --git a/dashboard/src/components/LocalOnlyNotice.jsx b/dashboard/src/components/LocalOnlyNotice.jsx index 7a09e8f3..2ade864d 100644 --- a/dashboard/src/components/LocalOnlyNotice.jsx +++ b/dashboard/src/components/LocalOnlyNotice.jsx @@ -2,7 +2,7 @@ import React from "react"; import { Download, ArrowUpRight } from "lucide-react"; import { copy } from "../lib/copy"; -const RELEASES_URL = "https://github.com/mm7894215/TokenTracker/releases/latest"; +const RELEASES_URL = "https://github.com/pitimon/TokenTracker/releases/latest"; /** * Empty state for local-first pages (Limits, Skills) when viewed on the diff --git a/dashboard/src/pages/WidgetsPage.jsx b/dashboard/src/pages/WidgetsPage.jsx index 4aa2f0b9..a8ff65ed 100644 --- a/dashboard/src/pages/WidgetsPage.jsx +++ b/dashboard/src/pages/WidgetsPage.jsx @@ -525,7 +525,7 @@ function HeaderCta() { if (platform === "mac-web") { return ( { diff --git a/dashboard/src/ui/dashboard/components/DataDetails.jsx b/dashboard/src/ui/dashboard/components/DataDetails.jsx index 44342790..98611432 100644 --- a/dashboard/src/ui/dashboard/components/DataDetails.jsx +++ b/dashboard/src/ui/dashboard/components/DataDetails.jsx @@ -8,24 +8,15 @@ function splitProjectKey(value) { return { owner: owner || "", repo: repo || "" }; } +// Local icon only. `owner` comes from a repo the user has checked out, so an +// would disclose that name — a +// private one included — to a third party straight from the browser. An image +// load leaks exactly what a fetch does, which is how issue 100 went unnoticed. function ProjectAvatar({ projectKey, projectRef }) { - const [imageFailed, setImageFailed] = useState(false); const normalizedRef = typeof projectRef === "string" ? projectRef.replace("https://github.com/", "") : ""; const { owner, repo } = splitProjectKey(projectKey || normalizedRef); const repoId = owner && repo ? `${owner}/${repo}` : projectKey; - const avatarUrl = owner && !imageFailed ? `https://github.com/${owner}.png?size=80` : ""; - - if (avatarUrl) { - return ( - setImageFailed(true)} - /> - ); - } return (
diff --git a/dashboard/src/ui/dashboard/components/ProjectUsagePanel.jsx b/dashboard/src/ui/dashboard/components/ProjectUsagePanel.jsx index 1c8ea9dd..7c2033b4 100644 --- a/dashboard/src/ui/dashboard/components/ProjectUsagePanel.jsx +++ b/dashboard/src/ui/dashboard/components/ProjectUsagePanel.jsx @@ -1,26 +1,18 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { Select } from "../../components"; import { copy } from "../../../lib/copy"; import { formatCompactNumber, toDisplayNumber, toFiniteNumber } from "../../../lib/format"; -import { shouldFetchGithubStars } from "../util/should-fetch-github-stars.js"; import { ProviderIcon } from "./ProviderIcon"; const LIMIT_OPTIONS = [3, 6, 10]; const DEFAULT_LIMIT = 10; -const REPO_META_CACHE = new Map(); - function splitRepoKey(value) { if (typeof value !== "string") return { owner: "", repo: "" }; const [owner, repo] = value.split("/"); return { owner: owner || "", repo: repo || "" }; } -function normalizeStars(value) { - if (!Number.isFinite(value)) return null; - return Math.max(0, Math.round(value)); -} - function resolveTokens(entry) { if (!entry) return null; const total = entry.total_tokens ?? null; @@ -33,65 +25,6 @@ function resolveTokens(entry) { return billable ?? total ?? null; } -function resolveRepoMeta(repoId) { - if (!repoId) return null; - return REPO_META_CACHE.get(repoId) || null; -} - -function cacheRepoMeta(repoId, meta) { - if (!repoId || !meta) return; - REPO_META_CACHE.set(repoId, meta); -} - -function useGithubRepoMeta(repoId) { - const [state, setState] = useState(() => resolveRepoMeta(repoId) || null); - - useEffect(() => { - if (!repoId) return; - const cached = resolveRepoMeta(repoId); - if (cached) { - setState(cached); - return; - } - - if (typeof window === "undefined") return; - const prefersReducedMotion = - typeof window.matchMedia === "function" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches; - const screenshotCapture = - typeof document !== "undefined" && - (document.documentElement?.classList.contains("screenshot-capture") || - document.body?.classList.contains("screenshot-capture")); - if (!shouldFetchGithubStars({ prefersReducedMotion, screenshotCapture })) { - return; - } - - let active = true; - fetch(`https://api.github.com/repos/${repoId}`) - .then((res) => res.json()) - .then((data) => { - if (!active) return; - const meta = { - stars: normalizeStars(data?.stargazers_count), - avatarUrl: typeof data?.owner?.avatar_url === "string" ? data.owner.avatar_url : null, - }; - cacheRepoMeta(repoId, meta); - setState(meta); - }) - .catch(() => { - if (!active) return; - const meta = { stars: null, avatarUrl: null }; - cacheRepoMeta(repoId, meta); - setState(meta); - }); - - return () => { - active = false; - }; - }, [repoId]); - - return state; -} export function ProjectUsagePanel({ entries = [], @@ -103,7 +36,6 @@ export function ProjectUsagePanel({ }) { const placeholder = copy("shared.placeholder.short"); const tokensLabel = copy("dashboard.projects.tokens_label"); - const starsLabel = copy("dashboard.projects.stars_label"); const emptyLabel = copy("dashboard.projects.empty"); const limitLabel = copy("dashboard.projects.limit_label"); const limitAria = copy("dashboard.projects.limit_aria"); @@ -162,7 +94,6 @@ export function ProjectUsagePanel({ entry={entry} placeholder={placeholder} tokensLabel={tokensLabel} - starsLabel={starsLabel} tokenFormatOptions={tokenFormatOptions} /> ))} @@ -176,25 +107,13 @@ function ProjectUsageCard({ entry, placeholder, tokensLabel, - starsLabel, tokenFormatOptions, }) { const repoKey = typeof entry?.project_key === "string" ? entry.project_key : ""; const projectRef = typeof entry?.project_ref === "string" ? entry.project_ref : ""; - const { owner, repo } = splitRepoKey( + const { repo } = splitRepoKey( repoKey || projectRef.replace("https://github.com/", "") ); - const repoId = owner && repo ? `${owner}/${repo}` : repoKey; - const meta = useGithubRepoMeta(repoId); - const avatarUrl = - meta?.avatarUrl || (owner ? `https://github.com/${owner}.png?size=80` : ""); - const starsRaw = meta?.stars; - const starsFull = - starsRaw == null ? placeholder : toDisplayNumber(starsRaw); - const starsCompact = - starsRaw == null - ? placeholder - : formatCompactNumber(starsRaw, tokenFormatOptions); const tokensRaw = resolveTokens(entry); const tokensFull = tokensRaw == null ? placeholder : toDisplayNumber(tokensRaw); @@ -205,19 +124,17 @@ function ProjectUsageCard({ return (
- {avatarUrl ? ( - - ) : ( -
- -
- )} + {/* Local icon only. This row is keyed by a repo you have checked out, so + fetching a remote avatar would send that name — including a private + one — to a third party from the user's browser. See issue 100. */} +
+ +
{repo || repoKey || placeholder}
- ★ {starsCompact} {tokensCompact}
diff --git a/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx b/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx index 2e9de01f..c4dac182 100644 --- a/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx +++ b/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx @@ -39,13 +39,16 @@ function renderDetails(props = {}) { } describe("DataDetails", () => { - it("uses an owner avatar for project usage rows instead of a letter initial", () => { + it("never loads a remote avatar for a project row (issue 100)", () => { const { container } = renderDetails(); fireEvent.click(screen.getByRole("tab", { name: "Project Usage" })); - const avatar = container.querySelector('img[src="https://github.com/pitimon.png?size=80"]'); - expect(avatar).toBeInTheDocument(); + // The owner name comes from a repo the user has checked out. An + // pointing at the owner's github.com avatar discloses it to a third party from the + // browser just as a fetch would — that is how issue 100 shipped unnoticed. + expect(container.querySelector('img[src*="github.com"]')).toBeNull(); + expect(container.querySelector("img")).toBeNull(); expect(screen.queryByText("P")).not.toBeInTheDocument(); }); diff --git a/dashboard/src/ui/dashboard/components/__tests__/ProjectUsagePanel.test.jsx b/dashboard/src/ui/dashboard/components/__tests__/ProjectUsagePanel.test.jsx index 36797305..d6674ea7 100644 --- a/dashboard/src/ui/dashboard/components/__tests__/ProjectUsagePanel.test.jsx +++ b/dashboard/src/ui/dashboard/components/__tests__/ProjectUsagePanel.test.jsx @@ -30,7 +30,9 @@ describe("ProjectUsagePanel", () => { const { container } = render(); expect(screen.getByText("hello")).toBeInTheDocument(); - expect(screen.getByText(/★/)).toBeInTheDocument(); + // No star count and no remote avatar: both were fetched from + // api.github.com with a checked-out repo name in the URL path (issue 100). + expect(screen.queryByText(/★/)).toBeNull(); expect(container.querySelector("a[href]")).toBeNull(); }); diff --git a/outbound-hosts.json b/outbound-hosts.json new file mode 100644 index 00000000..0fa01ee2 --- /dev/null +++ b/outbound-hosts.json @@ -0,0 +1,245 @@ +{ + "_meta": { + "purpose": "Every external host this codebase can reach, and why. The README's privacy table is checked against this file by scripts/validate-outbound.cjs, so the table stops being a claim someone has to remember to update.", + "why": "TokenTracker's headline claim is that your usage data stays on your machine. That claim was written by grepping src/ and never dashboard/src/, which missed four browser-side disclosures including one that sent private repo names to GitHub (#100). A validator does not forget a directory.", + "adding_a_host": "Add an entry here, then add or amend the matching row in README.md's 'Outbound calls, on your behalf only' table. CI fails if code reaches a host absent here, or if a user_data host is missing from the README.", + "fields": { + "from": "server = the local Node process. browser = the user's browser, i.e. it happens even though we call ourselves local-first.", + "user_data": "true when the request itself discloses something about the user or their work \u2014 a repo name in the path, an account token, their IP. false for anonymous public fetches.", + "readme": "true when this host must appear in the README outbound table. Every user_data host must." + } + }, + "hosts": [ + { + "host": "raw.githubusercontent.com", + "from": "server", + "user_data": false, + "readme": true, + "purpose": "Downloads the public LiteLLM price list. Anonymous; nothing is sent. Works offline from the bundled seed snapshot.", + "seen_in": [ + "src/lib/pricing/litellm-fetcher.js", + "src/lib/skills-manager.js" + ] + }, + { + "host": "api.anthropic.com", + "from": "server", + "user_data": true, + "readme": true, + "purpose": "Asks Anthropic about the user's own plan limits, using credentials already on the machine.", + "seen_in": [ + "src/lib/usage-limits.js" + ] + }, + { + "host": "chatgpt.com", + "from": "server", + "user_data": true, + "readme": true, + "purpose": "Codex plan limits for the signed-in account.", + "seen_in": [ + "src/lib/usage-limits.js" + ] + }, + { + "host": "api.openai.com", + "from": "server", + "user_data": true, + "readme": true, + "purpose": "OpenAI usage/limits for the signed-in account.", + "seen_in": [ + "src/lib/subscriptions.js" + ] + }, + { + "host": "auth.openai.com", + "from": "server", + "user_data": true, + "readme": true, + "purpose": "Refreshes the OpenAI credential when it expires.", + "seen_in": [ + "src/lib/codex-token-refresh.js" + ] + }, + { + "host": "cursor.com", + "from": "server", + "user_data": true, + "readme": true, + "purpose": "Cursor plan usage for the signed-in account.", + "seen_in": [ + "src/lib/cursor-config.js" + ] + }, + { + "host": "www.cursor.com", + "from": "server", + "user_data": true, + "readme": true, + "purpose": "Same as cursor.com; both hosts appear in Cursor's own endpoints.", + "seen_in": [ + "src/lib/cursor-config.js" + ] + }, + { + "host": "cloudcode-pa.googleapis.com", + "from": "server", + "user_data": true, + "readme": true, + "purpose": "Gemini Code Assist quota for the signed-in account.", + "seen_in": [ + "src/lib/usage-limits.js" + ] + }, + { + "host": "oauth2.googleapis.com", + "from": "server", + "user_data": true, + "readme": true, + "purpose": "Refreshes the Google credential when it expires.", + "seen_in": [ + "src/lib/usage-limits.js" + ] + }, + { + "host": "api.kimi.com", + "from": "server", + "user_data": true, + "readme": true, + "purpose": "Kimi plan usage for the signed-in account.", + "seen_in": [ + "src/lib/usage-limits.js" + ] + }, + { + "host": "auth.kimi.com", + "from": "server", + "user_data": true, + "readme": true, + "purpose": "Refreshes the Kimi credential when it expires.", + "seen_in": [ + "src/lib/usage-limits.js" + ] + }, + { + "host": "api.z.ai", + "from": "server", + "user_data": true, + "readme": true, + "purpose": "Z.AI / GLM coding-plan quota, using ZAI_API_KEY or a reused Anthropic-compatible token.", + "seen_in": [ + "src/lib/usage-limits.js" + ] + }, + { + "host": "api.github.com", + "from": "both", + "user_data": true, + "readme": true, + "purpose": "Server: GitHub Copilot quota for the signed-in account. Browser: the header star count for THIS repository only \u2014 a fixed public repo that says nothing about the user. The per-project variant, which put a repo you have checked out in the URL path, was removed in #100. Also used server-side by the Skills tab to read public repository metadata.", + "seen_in": [ + "src/lib/usage-limits.js", + "dashboard/src/ui/components/HeaderGithubStar.jsx", + "src/lib/skills-manager.js" + ] + }, + { + "host": "github.com", + "from": "both", + "user_data": false, + "readme": false, + "purpose": "Outbound links in the dashboard (repository, releases, skill sources) plus server-side reads of public skill repositories. A link is not a request until the user clicks it. No avatar or image is loaded from here any more (#100).", + "seen_in": [ + "dashboard/src/components/LocalOnlyNotice.jsx", + "dashboard/src/lib/mock-data.ts", + "dashboard/src/pages/SkillDetailPanel.jsx", + "dashboard/src/pages/SkillsPage.jsx", + "dashboard/src/pages/WidgetsPage.jsx", + "dashboard/src/ui/components/HeaderGithubStar.jsx", + "dashboard/src/ui/dashboard/components/DataDetails.jsx", + "dashboard/src/ui/dashboard/components/ProjectUsagePanel.jsx", + "src/commands/init.js", + "src/lib/skills-manager.js" + ] + }, + { + "host": "skills.sh", + "from": "server", + "user_data": false, + "readme": true, + "purpose": "Public skills directory used by the Skills tab to search and list available skills. The query is what you typed into that tab; no usage data is attached. Only reached when the Skills tab is opened.", + "seen_in": [ + "src/lib/skills-manager.js" + ] + }, + { + "host": "open.er-api.com", + "from": "browser", + "user_data": false, + "readme": true, + "purpose": "Public USD exchange rates, so costs can be shown in a local currency. Anonymous; the request carries no usage data. Only fires when a non-USD currency is selected.", + "seen_in": [ + "dashboard/src/lib/exchange-rate.ts" + ] + }, + { + "host": "ip.net.coffee", + "from": "server", + "user_data": true, + "readme": true, + "purpose": "Backs the optional IP-check page, proxied through the local server at /proxy/ipcheck. Only reached when that page is opened.", + "seen_in": [ + "src/lib/local-api.js", + "dashboard/src/pages/IpCheckPage.jsx" + ] + }, + { + "host": "1.1.1.1", + "from": "browser", + "user_data": true, + "readme": true, + "purpose": "IP-check page only: Cloudflare's trace endpoint, which is how the page learns your public IP. Showing you your own IP is that page's entire purpose.", + "seen_in": [ + "dashboard/src/pages/IpCheckPage.jsx" + ] + }, + { + "host": "claude.ai", + "from": "browser", + "user_data": true, + "readme": true, + "purpose": "IP-check page only: a reachability probe measuring whether Anthropic is reachable from this network.", + "seen_in": [ + "dashboard/src/pages/IpCheckPage.jsx" + ] + }, + { + "host": "www.anthropic.com", + "from": "browser", + "user_data": true, + "readme": true, + "purpose": "IP-check page only: the second reachability probe, alongside claude.ai.", + "seen_in": [ + "dashboard/src/pages/IpCheckPage.jsx" + ] + }, + { + "host": "registry.npmjs.org", + "from": "server", + "user_data": false, + "readme": true, + "purpose": "Not called by our code \u2014 this is npx fetching the package when TokenTracker is started that way. Listed because a user watching their network sees it and deserves an explanation.", + "seen_in": [] + } + ], + "ignored_prefixes": [ + "http://localhost", + "http://127.0.0.1", + "https://local.tokentracker", + "http://www.w3.org", + "https://example.com", + "http://evil", + "https://codex.ai", + "https://github.com/BerriAI" + ] +} diff --git a/package.json b/package.json index 766dccca..449d2ecb 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "access": "public" }, "scripts": { - "ci:local": "npm run dashboard:build && npm --prefix dashboard run test && npm test && npm run validate:copy && npm run validate:ui-hardcode && npm run validate:guardrails && npm run validate:curated-expiry && npm run validate:version-lockstep && npm run docs:openwiki:check && node --test test/architecture-guardrails.test.js", + "ci:local": "npm run dashboard:build && npm --prefix dashboard run test && npm test && npm run validate:copy && npm run validate:ui-hardcode && npm run validate:guardrails && npm run validate:curated-expiry && npm run validate:version-lockstep && npm run validate:outbound && npm run docs:openwiki:check && node --test test/architecture-guardrails.test.js", "copy:pull": "node scripts/copy-sync.cjs pull", "copy:push": "node scripts/copy-sync.cjs push", "dashboard:build": "npm --prefix dashboard run build", @@ -47,7 +47,8 @@ "validate:retros": "node scripts/validate-retros.cjs", "validate:ui-hardcode": "node scripts/ops/validate-ui-hardcode.cjs", "validate:version-lockstep": "node scripts/validate-version-lockstep.cjs", - "version": "node scripts/sync-desktop-version.cjs" + "version": "node scripts/sync-desktop-version.cjs", + "validate:outbound": "node scripts/validate-outbound.cjs" }, "dependencies": { "@mongodb-js/zstd": "^2.0.1", diff --git a/scripts/validate-outbound.cjs b/scripts/validate-outbound.cjs new file mode 100644 index 00000000..9e677947 --- /dev/null +++ b/scripts/validate-outbound.cjs @@ -0,0 +1,219 @@ +// Checks that every external host reachable from this codebase is declared in +// outbound-hosts.json, and that the README's privacy table names the ones that +// carry user data. +// +// Why this exists: the README's outbound-call table is the product's +// highest-stakes claim, and until now it was maintained by hand. The audit that +// wrote it grepped `src/` and never `dashboard/src/`, so it missed four +// browser-side disclosures — including one that put the name of a repository +// you have checked out, private ones included, in a request URL to GitHub +// (#100). The claim was researched. It just wasn't researched exhaustively, and +// nothing would ever have said so. +// +// It scans for host literals ANYWHERE, not just inside `fetch(` — the leak that +// prompted this was an , and , CSS url(), new Image() and +// srcSet disclose exactly as much as a fetch does. Matching on call sites would +// have reproduced the original blind spot in code. +// +// What it cannot see: a host assembled at runtime from parts. That hole is +// closed by convention rather than by grep — dashboard network calls go through +// dashboard/src/lib/api.ts, and validate:guardrails rejects a raw fetch( to a +// non-local host elsewhere. Ship both halves or neither. + +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = path.resolve(__dirname, ".."); +const SCAN_DIRS = ["src", "dashboard/src"]; +const SCAN_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".cjs", ".mjs"]); +const HOST_RE = /https?:\/\/([a-zA-Z0-9._-]+)/g; + +function walk(dir, out = []) { + if (!fs.existsSync(dir)) return out; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist") continue; + walk(full, out); + } else if (SCAN_EXTENSIONS.has(path.extname(entry.name))) { + out.push(full); + } + } + return out; +} + +// Test files describe hosts they never contact — a fixture URL is not an +// outbound call. Excluded so the inventory stays a list of real destinations. +function isTestFile(filePath) { + return /\.test\.[jt]sx?$/.test(filePath) || /(^|[\\/])__tests__[\\/]/.test(filePath); +} + +function collectHosts({ root = ROOT } = {}) { + const found = new Map(); // host -> Set(relative file) + for (const dir of SCAN_DIRS) { + for (const filePath of walk(path.join(root, dir))) { + if (isTestFile(filePath)) continue; + const content = fs.readFileSync(filePath, "utf8"); + const relative = path.relative(root, filePath); + for (const match of content.matchAll(HOST_RE)) { + const host = match[1]; + if (!found.has(host)) found.set(host, new Set()); + found.get(host).add(relative); + } + } + } + return found; +} + +// --- Half B: the hole a host-literal scan cannot see ------------------------- +// `fetch(someVar)` reaches a host that never appears as a literal, so the scan +// above is blind to it. +// +// The rule is narrow on purpose: flag a non-literal fetch target ONLY in a file +// that also contains an external host literal. A dashboard file with no external +// host cannot construct one out of nothing — it would have to import a URL +// constant, and the file holding that constant is itself scanned and must be +// allowlisted. This clears the local API modules, whose targets are variables +// but always `new URL("/functions/...", window.location.origin)`, without +// weakening the check where it matters. +// +// A first attempt flagged those local modules too. That rule would have been +// deleted the first time it cried wolf, which is worse than not having it. +const DYNAMIC_FETCH_ALLOWLIST = new Map([ + [ + "dashboard/src/pages/IpCheckPage.jsx", + "Builds probe URLs from a literal target list in the same file; every host is declared in outbound-hosts.json.", + ], + [ + "dashboard/src/lib/exchange-rate.ts", + "Single declared host (open.er-api.com) held in an exported constant.", + ], +]); + +function externalHostsIn(content, inventory) { + const hosts = new Set(); + for (const match of content.matchAll(HOST_RE)) { + if (!isIgnored(match[1], inventory)) hosts.add(match[1]); + } + return hosts; +} + +function checkDynamicFetches(root, inventory) { + const findings = []; + for (const filePath of walk(path.join(root, "dashboard/src"))) { + if (isTestFile(filePath)) continue; + const relative = path.relative(root, filePath); + if (DYNAMIC_FETCH_ALLOWLIST.has(relative)) continue; + const content = fs.readFileSync(filePath, "utf8"); + if (externalHostsIn(content, inventory).size === 0) continue; + content.split("\n").forEach((line, index) => { + const match = line.match(/\bfetch\s*\(([^,)]*)/); + if (!match) return; + const arg = match[1]; + if (/^\s*[`'"]\//.test(arg)) return; // relative literal + if (/[`'"]https?:\/\//.test(arg)) return; // absolute literal, covered by the host scan + findings.push( + `${relative}:${index + 1} fetch() builds its target at runtime in a file that also names an` + + ` external host — route it through the local API, or add the file to DYNAMIC_FETCH_ALLOWLIST` + + ` in scripts/validate-outbound.cjs with a reason`, + ); + }); + } + return findings; +} + +function loadInventory(root = ROOT) { + const file = path.join(root, "outbound-hosts.json"); + if (!fs.existsSync(file)) return null; + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function isIgnored(host, inventory) { + return (inventory.ignored_prefixes || []).some((prefix) => { + const prefixHost = prefix.replace(/^https?:\/\//, "").split("/")[0]; + return prefixHost === host; + }); +} + +function checkOutbound({ root = ROOT } = {}) { + const inventory = loadInventory(root); + if (!inventory) return ["outbound-hosts.json is missing"]; + + const findings = []; + const declared = new Map((inventory.hosts || []).map((h) => [h.host, h])); + const found = collectHosts({ root }); + + // 1. Anything the code can reach must be declared. This is the check that + // would have caught #100 the day it was written. + for (const [host, files] of found) { + if (declared.has(host) || isIgnored(host, inventory)) continue; + findings.push( + `undeclared outbound host '${host}' in ${[...files].sort().join(", ")}` + + ` — add it to outbound-hosts.json (and the README table if it carries user data)`, + ); + } + + // 2. A declaration that no longer matches any code is stale. Entries with an + // empty seen_in are deliberate (npx), so they are exempt. + for (const entry of inventory.hosts || []) { + if (Array.isArray(entry.seen_in) && entry.seen_in.length === 0) continue; + if (!found.has(entry.host)) { + findings.push( + `outbound-hosts.json declares '${entry.host}' but no code references it — remove it or fix the entry`, + ); + } + } + + // 3. A declared host may only be reached from the files that declare it. + // Without this, `seen_in` is documentation: re-adding the exact call that + // caused #100 to ProjectUsagePanel would pass, because api.github.com is + // legitimately declared for the header star count elsewhere. The question + // that matters is not only "which hosts can we reach" but "from where". + for (const entry of inventory.hosts || []) { + const allowed = new Set(entry.seen_in || []); + if (allowed.size === 0) continue; + for (const file of found.get(entry.host) || []) { + if (allowed.has(file)) continue; + findings.push( + `'${entry.host}' is referenced in ${file}, which is not in its seen_in list` + + ` — add the file to outbound-hosts.json if the call belongs there, and re-read the` + + ` purpose field before you do`, + ); + } + } + + // 4. Every host that discloses something about the user must be in the + // README table. The table is the promise; this is what keeps it true. + const readmePath = path.join(root, "README.md"); + if (fs.existsSync(readmePath)) { + const readme = fs.readFileSync(readmePath, "utf8"); + for (const entry of inventory.hosts || []) { + if (!entry.readme) continue; + if (!readme.includes(entry.host)) { + findings.push( + `README.md does not mention '${entry.host}', which outbound-hosts.json marks as` + + (entry.user_data ? " carrying user data" : " user-visible"), + ); + } + } + } + + findings.push(...checkDynamicFetches(root, inventory)); + + return findings; +} + +function main() { + const findings = checkOutbound(); + if (findings.length === 0) { + const count = (loadInventory() || {}).hosts?.length ?? 0; + console.log(`Outbound inventory ok: ${count} declared hosts, all reachable ones accounted for.`); + return; + } + for (const finding of findings) console.error(`- ${finding}`); + process.exitCode = 1; +} + +if (require.main === module) main(); + +module.exports = { checkOutbound, collectHosts, checkDynamicFetches }; diff --git a/src/commands/init.js b/src/commands/init.js index 7e5760fe..50b157b4 100644 --- a/src/commands/init.js +++ b/src/commands/init.js @@ -251,7 +251,7 @@ function renderLocalSuccess({ firstSync } = {}) { // One-shot, post-success star CTA. `init` is run once per machine, so // this is the only place a CLI user naturally sees the project's GitHub // URL — and they're at peak satisfaction. - ` ${color("⭐ Star us if useful: https://github.com/mm7894215/TokenTracker", DIM)}`, + ` ${color("⭐ Star us if useful: https://github.com/pitimon/TokenTracker", DIM)}`, "", ); process.stdout.write(lines.join("\n")); diff --git a/test/dashboard-layout-adjustments.test.js b/test/dashboard-layout-adjustments.test.js index 4eb66334..a407d0be 100644 --- a/test/dashboard-layout-adjustments.test.js +++ b/test/dashboard-layout-adjustments.test.js @@ -78,17 +78,25 @@ test("ProjectUsagePanel lays out cards in responsive grid", () => { ); }); -test("ProjectUsagePanel formats star values compactly", () => { +test("ProjectUsagePanel reaches no external host (issue 100)", () => { + // These rows are keyed by repositories the user has checked out. Fetching a + // star count or an owner avatar put that name — a private one included — into + // a URL sent to GitHub from the user's browser. The panel had both. + // Asserted against the source, not the render, so it also fails if someone + // adds an or a rather than a fetch. const src = readFile(projectUsagePath); + // The one permitted occurrence is a prefix stripped off a stored project_ref, + // never a request target. Remove it, then no absolute URL may remain. + const withoutRefPrefix = src.split('"https://github.com/"').join(""); assert.ok( - src.includes("formatCompactNumber(starsRaw"), - "expected project usage panel to compact star values", + !/https?:\/\//.test(withoutRefPrefix), + "no absolute URL may appear in this component", ); + assert.ok(!src.includes(" { +test("ProjectUsagePanel renders token info", () => { const src = readFile(projectUsagePath); - assert.ok(src.includes("starsCompact"), "expected project usage card to show stars"); assert.ok(src.includes("tokensCompact"), "expected project usage card to show tokens"); }); diff --git a/test/outbound-inventory.test.js b/test/outbound-inventory.test.js new file mode 100644 index 00000000..2d5c719d --- /dev/null +++ b/test/outbound-inventory.test.js @@ -0,0 +1,177 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { test } = require("node:test"); + +const { checkOutbound, collectHosts } = require("../scripts/validate-outbound.cjs"); + +// A throwaway repo shaped like this one. The validator reads the tree it is +// pointed at, so probing the real tree would mean editing tracked source — the +// hazard already caught once in test/openwiki-facts.test.js. +function fixture({ files = {}, hosts = [], ignored = [], readme = "" }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tt-outbound-")); + for (const [relative, content] of Object.entries(files)) { + const full = path.join(root, relative); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + fs.writeFileSync( + path.join(root, "outbound-hosts.json"), + JSON.stringify({ hosts, ignored_prefixes: ignored }), + ); + fs.writeFileSync(path.join(root, "README.md"), readme); + return root; +} + +const declared = (host, extra = {}) => ({ + host, + from: "server", + user_data: false, + readme: false, + purpose: "test", + seen_in: ["src/a.js"], + ...extra, +}); + +test("the repo as committed has a complete outbound inventory", () => { + assert.deepEqual(checkOutbound(), []); +}); + +test("a host reachable in an is caught, not just one inside fetch()", () => { + // This is the defect that prompted the whole check. `ProjectUsagePanel` sent + // the name of a repo you had checked out to GitHub — once through fetch(), + // and once through an template literal. A call-site scan sees only + // the first, which is the same blind spot that let it ship. + const root = fixture({ + files: { + "dashboard/src/Panel.jsx": 'export const A = () => ;\n', + }, + }); + const findings = checkOutbound({ root }); + assert.equal(findings.length, 1); + assert.match(findings[0], /undeclared outbound host 'evil\.example'/); + assert.match(findings[0], /dashboard\/src\/Panel\.jsx/); +}); + +test("dashboard/src is scanned, not only src", () => { + // The audit that wrote the README table grepped `src/` and stopped there. + // That omission is the reason this file exists, so it gets its own test. + const root = fixture({ + files: { "dashboard/src/x.ts": 'const u = "https://only-in-dashboard.example/v1";\n' }, + }); + assert.ok( + checkOutbound({ root }).some((f) => f.includes("only-in-dashboard.example")), + "a host present only under dashboard/src must still be reported", + ); +}); + +test("a declared host that no longer exists in code is reported as stale", () => { + const root = fixture({ hosts: [declared("gone.example")] }); + assert.ok(checkOutbound({ root }).some((f) => f.includes("no code references it"))); +}); + +test("a user-data host missing from the README fails the build", () => { + // The table is the promise. A host can be declared honestly in JSON and still + // be invisible to the person reading the privacy section. + const root = fixture({ + files: { "src/a.js": 'fetch("https://tracker.example/collect");\n' }, + hosts: [declared("tracker.example", { user_data: true, readme: true })], + readme: "# TokenTracker\n\nNothing here mentions it.\n", + }); + const findings = checkOutbound({ root }); + assert.equal(findings.length, 1); + assert.match(findings[0], /README\.md does not mention 'tracker\.example'/); + assert.match(findings[0], /carrying user data/); +}); + +test("a declared and documented host passes", () => { + const root = fixture({ + files: { "src/a.js": 'fetch("https://ok.example/x");\n' }, + hosts: [declared("ok.example", { readme: true })], + readme: "| Pricing | `ok.example` | server | why |\n", + }); + assert.deepEqual(checkOutbound({ root }), []); +}); + +test("test fixtures naming a host are not treated as outbound calls", () => { + const root = fixture({ + files: { "src/a.test.js": 'assert.equal(url, "https://fixture-only.example/x");\n' }, + }); + assert.deepEqual(checkOutbound({ root }), [], "a URL in a test is not a destination"); + assert.equal(collectHosts({ root }).has("fixture-only.example"), false); +}); + +test("ignored_prefixes cover loopback and non-network literals", () => { + const root = fixture({ + files: { "src/a.js": 'const u = "http://localhost:7680"; const ns = "http://www.w3.org/2000/svg";\n' }, + ignored: ["http://localhost", "http://www.w3.org"], + }); + assert.deepEqual(checkOutbound({ root }), []); +}); + +test("a declared host reached from an undeclared file is caught", () => { + // The check that closes the actual #100 shape. api.github.com is legitimately + // declared for the header star count, so re-adding the per-project call would + // pass a check that only asks "which hosts can we reach". The question that + // matters is also "from where". + const root = fixture({ + files: { + "dashboard/src/Allowed.jsx": 'const u = "https://shared.example/ok";\n', + "dashboard/src/Sneaky.jsx": 'const u = `https://shared.example/${repo}`;\n', + }, + hosts: [declared("shared.example", { seen_in: ["dashboard/src/Allowed.jsx"] })], + }); + const findings = checkOutbound({ root }); + assert.equal(findings.length, 1); + assert.match(findings[0], /Sneaky\.jsx, which is not in its seen_in list/); +}); + +test("seen_in is enforced, so it cannot drift into decoration", () => { + // Every seen_in list in the committed inventory is exact. They were + // hand-written first and were wrong in six entries; the repo test above is + // what surfaced that, and this one states the expectation directly. + const root = fixture({ + files: { "src/a.js": 'const u = "https://x.example/1";\n' }, + hosts: [declared("x.example", { seen_in: ["src/nonexistent.js"] })], + }); + assert.ok(checkOutbound({ root }).some((f) => f.includes("not in its seen_in list"))); +}); + +// --- Half B ------------------------------------------------------------------ + +test("a runtime-built fetch target is caught where the file can reach outside", () => { + // The host-literal scan cannot see `fetch(target.url)`. This is the half that + // stops the first dynamically-built URL from walking through the gate. + const root = fixture({ + files: { + "dashboard/src/Bad.jsx": [ + 'const BASE = "https://exfil.example";', + "export const go = (p) => fetch(buildUrl(p));", + "", + ].join("\n"), + }, + hosts: [declared("exfil.example", { seen_in: ["dashboard/src/Bad.jsx"] })], + }); + const findings = checkOutbound({ root }); + assert.ok( + findings.some((f) => f.includes("builds its target at runtime")), + `expected the dynamic-target finding, got: ${JSON.stringify(findings)}`, + ); +}); + +test("a runtime-built fetch is left alone when the file names no external host", () => { + // The local API modules pass their target as a variable — always + // `new URL("/functions/...", window.location.origin)`. A rule that flagged + // those would be deleted the first time it cried wolf, so it must not. + const root = fixture({ + files: { + "dashboard/src/lib/api.ts": [ + 'const url = new URL(`/functions/${slug}`, window.location.origin);', + "const r = await fetch(url.toString(), { cache: 'no-store' });", + "", + ].join("\n"), + }, + }); + assert.deepEqual(checkOutbound({ root }), []); +}); From ce05a68f9c8937593c97006b9cb073de9a3c9e2d Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Sat, 25 Jul 2026 14:58:12 +0700 Subject: [PATCH 2/2] fix(validate): the ignore list could exempt more than it named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ignored_prefixes` took URL prefixes and `isIgnored()` reduced each to its host, so the entry `https://github.com/BerriAI` silently exempted every github.com reference in the tree. It was masked only because github.com is also declared, and checked first — remove that declaration and the guard would have gone quiet across a whole domain with nothing to say so. A field that looks narrow and is not is worse than no field, and this one is the validator's own escape hatch. Now `ignored_hosts`, matched on exact host, with the BerriAI entry dropped as redundant. Each remaining entry carries why it is safe; all five were re-verified rather than assumed — `local.tokentracker` is a synthetic project_ref in rollout.js that is never requested, and codex.ai, example.com and evil appear only inside comments. A test asserts a path-shaped entry cannot exempt its host. --- outbound-hosts.json | 29 +++++++++++++++++++---------- scripts/validate-outbound.cjs | 10 ++++++---- test/outbound-inventory.test.js | 20 +++++++++++++++++--- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/outbound-hosts.json b/outbound-hosts.json index 0fa01ee2..c2bfba8a 100644 --- a/outbound-hosts.json +++ b/outbound-hosts.json @@ -232,14 +232,23 @@ "seen_in": [] } ], - "ignored_prefixes": [ - "http://localhost", - "http://127.0.0.1", - "https://local.tokentracker", - "http://www.w3.org", - "https://example.com", - "http://evil", - "https://codex.ai", - "https://github.com/BerriAI" - ] + "ignored_hosts": { + "_note": "Matched on host exactly. Not prefixes \u2014 a path here would be ignored, exempting the whole host.", + "hosts": [ + "localhost", + "127.0.0.1", + "local.tokentracker", + "www.w3.org", + "example.com", + "evil", + "codex.ai" + ], + "why": { + "localhost / 127.0.0.1": "loopback", + "local.tokentracker": "a synthetic project_ref identifier for claude-mem observer sessions (src/lib/rollout.js:19), never requested", + "www.w3.org": "the SVG XML namespace", + "example.com / evil": "appear only inside comments illustrating attacks and enterprise host shapes", + "codex.ai": "named in a comment about provider domains; not a request target" + } + } } diff --git a/scripts/validate-outbound.cjs b/scripts/validate-outbound.cjs index 9e677947..d68ffd81 100644 --- a/scripts/validate-outbound.cjs +++ b/scripts/validate-outbound.cjs @@ -128,11 +128,13 @@ function loadInventory(root = ROOT) { return JSON.parse(fs.readFileSync(file, "utf8")); } +// Exact host match only. An earlier version took URL prefixes and reduced them +// to their host, so an entry like "https://github.com/BerriAI" quietly exempted +// every github.com reference — a field that looked narrow and was not. This is +// the validator's own escape hatch; it must not be able to hide more than it says. function isIgnored(host, inventory) { - return (inventory.ignored_prefixes || []).some((prefix) => { - const prefixHost = prefix.replace(/^https?:\/\//, "").split("/")[0]; - return prefixHost === host; - }); + const ignored = inventory.ignored_hosts?.hosts || []; + return ignored.includes(host); } function checkOutbound({ root = ROOT } = {}) { diff --git a/test/outbound-inventory.test.js b/test/outbound-inventory.test.js index 2d5c719d..08ff8530 100644 --- a/test/outbound-inventory.test.js +++ b/test/outbound-inventory.test.js @@ -18,7 +18,7 @@ function fixture({ files = {}, hosts = [], ignored = [], readme = "" }) { } fs.writeFileSync( path.join(root, "outbound-hosts.json"), - JSON.stringify({ hosts, ignored_prefixes: ignored }), + JSON.stringify({ hosts, ignored_hosts: { hosts: ignored } }), ); fs.writeFileSync(path.join(root, "README.md"), readme); return root; @@ -102,14 +102,28 @@ test("test fixtures naming a host are not treated as outbound calls", () => { assert.equal(collectHosts({ root }).has("fixture-only.example"), false); }); -test("ignored_prefixes cover loopback and non-network literals", () => { +test("ignored_hosts covers loopback and non-network literals", () => { const root = fixture({ files: { "src/a.js": 'const u = "http://localhost:7680"; const ns = "http://www.w3.org/2000/svg";\n' }, - ignored: ["http://localhost", "http://www.w3.org"], + ignored: ["localhost", "www.w3.org"], }); assert.deepEqual(checkOutbound({ root }), []); }); +test("an ignore entry cannot exempt more than the host it names", () => { + // The escape hatch used to take URL prefixes and reduce them to their host, + // so "https://github.com/BerriAI" silently exempted every github.com + // reference. A field that looks narrow and is not is worse than no field. + const root = fixture({ + files: { "dashboard/src/a.jsx": 'const u = `https://github.com/${owner}.png`;\n' }, + ignored: ["github.com/BerriAI"], + }); + assert.ok( + checkOutbound({ root }).some((f) => f.includes("undeclared outbound host 'github.com'")), + "a path-shaped ignore entry must not exempt the host", + ); +}); + test("a declared host reached from an undeclared file is caught", () => { // The check that closes the actual #100 shape. api.github.com is legitimately // declared for the header star count, so re-adding the per-project call would