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..c2bfba8a --- /dev/null +++ b/outbound-hosts.json @@ -0,0 +1,254 @@ +{ + "_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_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/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..d68ffd81 --- /dev/null +++ b/scripts/validate-outbound.cjs @@ -0,0 +1,221 @@ +// 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")); +} + +// 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) { + const ignored = inventory.ignored_hosts?.hosts || []; + return ignored.includes(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..08ff8530 --- /dev/null +++ b/test/outbound-inventory.test.js @@ -0,0 +1,191 @@ +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_hosts: { hosts: 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_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: ["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 + // 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 }), []); +});