From fb5691b0dbf30a0ccdf0cd8dd3a7fec88dbe97c4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 4 Oct 2026 15:50:58 +0000 Subject: [PATCH 1/2] feat(tracker): declared visitors card + `crawlproof actors extension` (CLI 0.5.0) Dashboard: the project stats page gets a "Declared visitors" card after the headline tiles: declared humans / agents, contradictions in red, and named actors (only the viewer's own or public ones, via declaredSummary, the same filter the API uses). With nothing declared it is one muted line linking to Settings -> Declared actors. CLI: `crawlproof actors extension [--out] [--label]` mints a token and writes an unpacked MV3 extension (lib/tracker/declareExtension) whose single declarativeNetRequest rule sets Crawlproof-Actor on /api/track only, so an agent's browser declares itself without a code change and the sites it visits never see the token. Verified end to end: generated for riotcoder, loaded in Chrome for Testing, the visit counted on the actor. Fix: `actors revoke --token=` collided with the CLI-wide --token (API key override) and was sent as the bearer: 401 "Malformed token". Now --token-id. Broken since 0.4.0. Docs: the extension, how to load it, and the branded-Chrome caveat. @profullstack/crawlproof 0.4.0 -> 0.5.0. Co-Authored-By: Claude Opus 5.5 --- .../projects/[id]/stats/declared-card.tsx | 107 ++++++++++++++++++ .../dashboard/projects/[id]/stats/page.tsx | 14 +++ app/(marketing)/docs/statistics/page.tsx | 16 +++ cli/index.ts | 2 +- lib/tracker/actorsCli.ts | 56 ++++++++- lib/tracker/declareExtension.ts | 76 +++++++++++++ packages/cli/package.json | 2 +- packages/cli/src/cli.ts | 4 +- tests/tracker-declare-extension.test.ts | 104 +++++++++++++++++ tests/tracker-declared-actors.test.ts | 2 +- 10 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 app/(app)/dashboard/projects/[id]/stats/declared-card.tsx create mode 100644 lib/tracker/declareExtension.ts create mode 100644 tests/tracker-declare-extension.test.ts diff --git a/app/(app)/dashboard/projects/[id]/stats/declared-card.tsx b/app/(app)/dashboard/projects/[id]/stats/declared-card.tsx new file mode 100644 index 0000000..0594ae5 --- /dev/null +++ b/app/(app)/dashboard/projects/[id]/stats/declared-card.tsx @@ -0,0 +1,107 @@ +import Link from "next/link"; +import type { DeclaredSummary } from "@/lib/tracker/actorStore"; + +// Declared actors on this site (lib/tracker/actors.ts): what visitors SAID +// they are, kept apart from the measured tiles above it. Names appear only +// for the viewer's own actors or ones their owners made public; everyone else +// is in the per-kind totals and nowhere else (declaredSummary does the +// filtering, the same function the API and CLI read). +export function DeclaredCard({ summary, rangeLabel }: { summary: DeclaredSummary | null; rangeLabel: string }) { + // Null means the actor tables are unreadable (or not migrated): say nothing + // rather than print zeros that read as "nobody declared". + if (!summary) return null; + const { human, agent } = summary.totals; + const contradictions = human.contradictions + agent.contradictions; + + if (human.events + agent.events === 0) { + return ( +

+ No declared visitors in this window.{" "} + + Declare yourself or your agents → + +

+ ); + } + + return ( +
+
+
+

Declared visitors

+

+ {rangeLabel}. Self-reported, opt-in: an agent is believed and counted as a bot; a human + never overrides bot detection. +

+
+ + Manage actors → + +
+ +
+ + +
+
Contradictions
+
+ {contradictions.toLocaleString()} +
+
+ Hits declared human that detection called a bot +
+
+
+ + {summary.actors.length > 0 && ( +
+ + + + + + + + + + + + {summary.actors.map((a) => ( + + + + + + + + ))} + +
ActorKindPageviewsEventsContradicted
+ {a.name || a.email} + {a.name && {a.email}} + {!a.mine && (public)} + {a.kind}{a.pageviews.toLocaleString()}{a.events.toLocaleString()} + {a.contradictions.toLocaleString()} +
+
+ )} + {summary.actors.length === 0 && ( +

+ None of these actors are yours or public, so only the totals are shown. +

+ )} +
+ ); +} + +function Tile({ label, kind }: { label: string; kind: { actors: number; events: number; pageviews: number } }) { + return ( +
+
{label}
+
{kind.actors.toLocaleString()}
+
+ {kind.pageviews.toLocaleString()} pageviews, {kind.events.toLocaleString()} events +
+
+ ); +} diff --git a/app/(app)/dashboard/projects/[id]/stats/page.tsx b/app/(app)/dashboard/projects/[id]/stats/page.tsx index 8472824..a88f82a 100644 --- a/app/(app)/dashboard/projects/[id]/stats/page.tsx +++ b/app/(app)/dashboard/projects/[id]/stats/page.tsx @@ -23,6 +23,10 @@ import { AutoInstall } from "./auto-install"; import { LiveVisitors } from "./live-visitors"; import { StatsSubnav } from "./stats-subnav"; import { WhoToggle } from "./who-toggle"; +import { DeclaredCard } from "./declared-card"; +import { serviceClient } from "@/lib/supabase/service"; +import { declaredSummary } from "@/lib/tracker/actorStore"; +import { DECLARED_DEFINITION } from "@/lib/tracker/actors"; import { getOrMintInstallationToken } from "@/lib/github/installations"; import { listInstallationRepos } from "@/lib/github/app"; @@ -122,6 +126,14 @@ export default async function ProjectStatsPage({ // no connected installations, we just hide the button. const ghConfigured = !!(env.githubAppId && env.githubAppPrivateKey); const { data: { user } } = await supabase.auth.getUser(); + + // Declared actors (opt-in, self-reported). Read with the service client + // because naming an actor needs a join the viewer's RLS cannot see; + // declaredSummary itself filters names to the viewer's own or public ones. + // Best-effort: a failure hides the card instead of breaking the page. + const declared = user + ? await declaredSummary(serviceClient(), user.id, id, range, DECLARED_DEFINITION).catch(() => null) + : null; const installations: Array<{ installation_id: number; account_login: string }> = []; const ghRepos: Array<{ full_name: string; @@ -295,6 +307,8 @@ export default async function ProjectStatsPage({

{visitorsCaption}

)} + + {grandTotal === 0 && eventTotal === 0 ? (

diff --git a/app/(marketing)/docs/statistics/page.tsx b/app/(marketing)/docs/statistics/page.tsx index cae5bb2..3beba1a 100644 --- a/app/(marketing)/docs/statistics/page.tsx +++ b/app/(marketing)/docs/statistics/page.tsx @@ -147,6 +147,22 @@ https://example.com/?crp_actor=cpa_… # or from page code window.crawlproof?.("actor", "cpa_…"); // null forgets it`} +

+ For an agent's browser, generate an extension instead of changing + its code. It sends the token on{" "} + /api/track requests and nothing + else, so the sites the agent visits never see it (a blanket extra + header on every request would hand them the token): +

+
{`crawlproof actors extension mybot@example.com --out=./crawlproof-declare
+
+chromium --load-extension=./crawlproof-declare --disable-extensions-except=./crawlproof-declare
+# chrome-devtools-mcp: --chromeArg=--load-extension= --chromeArg=--disable-extensions-except=
+# Playwright: launchPersistentContext(profile, { args: [the same two flags] })`}
+

+ Use Chromium or Chrome for Testing: branded Google Chrome 137 and + later ignores --load-extension. +

It is self-reported, so the rule is one-way: a declared agent is believed and counted as a bot; a declared human is recorded but never diff --git a/cli/index.ts b/cli/index.ts index 4bd0b65..ac20813 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -1057,7 +1057,7 @@ async function main() { return await runActors(args.positional, args.flags as Record, (method, path, body) => apiCall(args, method, path, body), { write: (line: string) => process.stdout.write(`${line}\n`), error: (line: string) => console.error(line), - }); + }, { base: apiBase(args) }); case "dashboard": case "roi": case "tui": diff --git a/lib/tracker/actorsCli.ts b/lib/tracker/actorsCli.ts index 9ed9873..4cc8181 100644 --- a/lib/tracker/actorsCli.ts +++ b/lib/tracker/actorsCli.ts @@ -8,6 +8,10 @@ // Model and trust rule: lib/tracker/actors.ts. Opt-in and self-reported; an // agent is believed, a human never overrides bot detection. +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { declareExtensionFiles } from "./declareExtension"; + type Method = "GET" | "POST" | "PATCH" | "DELETE"; type ApiCall = (method: Method, path: string, body?: Record) => Promise<{ status: number; json: Record }>; type Out = { write: (line: string) => void; error: (line: string) => void }; @@ -27,7 +31,8 @@ export const ACTORS_USAGE = ` actors [list] [--json] actors add --kind=human|agent [--name=…] [--operator=] [--public] [--token-label=…] [--no-token] [--json] actors token [--label=…] - actors revoke [--token=] + actors revoke [--token-id=] + actors extension [--out=./crawlproof-declare] [--label=…] Declared actors: say who you are, and whether you are a person, on every site with the CrawlProof tracker. Opt-in and self-reported. A token (cpa_…) is the credential, never the email; send it as the @@ -36,6 +41,13 @@ export const ACTORS_USAGE = ` actors [list] [--json] is counted as a contradiction. Names are visible to you only unless --public. Your login address is verified on creation; any other gets a verification email. Needs an API token. + + \`actors extension\` mints a token and writes an unpacked Chrome + extension that sends it on /api/track requests ONLY, for an agent's + browser: chrome --load-extension=

--disable-extensions-except=. + (A blanket extra header on every request would hand the token to every + site the agent visits.) Chromium or Chrome for Testing; branded Chrome + 137+ ignores --load-extension. `; /** How to send a fresh actor token. Pure, for tests. */ @@ -57,6 +69,7 @@ export async function runActors( flags: Record, call: ApiCall, out: Out, + opts: { base?: string } = {}, ): Promise { const sub = positional[0] ?? "list"; const json = Boolean(flags.json); @@ -141,10 +154,12 @@ export async function runActors( if (r.status >= 400) return fail("revoke", r); const actor = find(actors, positional[1]); if (!actor) { - out.error("usage: crawlproof actors revoke [--token=] (no --token revokes the actor and every token)"); + out.error("usage: crawlproof actors revoke [--token-id=] (no --token-id revokes the actor and every token)"); return 2; } - const tokenId = typeof flags.token === "string" ? flags.token : undefined; + // Not --token: both CLIs read --token as the API key override, so a token id + // there was sent as the bearer and came back 401 "Malformed token". + const tokenId = typeof flags["token-id"] === "string" ? flags["token-id"] : undefined; const d = tokenId ? await call("DELETE", `/api/tracker/v1/actors/${actor.id}/tokens?token=${encodeURIComponent(tokenId)}`) : await call("DELETE", `/api/tracker/v1/actors/${actor.id}`); @@ -153,6 +168,39 @@ export async function runActors( return 0; } - out.error(`unknown: crawlproof actors ${sub} (expected: list | add | token | revoke)`); + if (sub === "extension") { + const { r, actors } = await list(); + if (r.status >= 400) return fail("extension", r); + const actor = find(actors, positional[1]); + if (!actor) { + out.error("usage: crawlproof actors extension [--out=./crawlproof-declare] [--label=…] (crawlproof actors list shows yours)"); + return 2; + } + const dir = resolve(typeof flags.out === "string" ? flags.out : "crawlproof-declare"); + const label = typeof flags.label === "string" ? flags.label : "browser extension"; + const m = await call("POST", `/api/tracker/v1/actors/${actor.id}/tokens`, { label }); + if (m.status >= 400) return fail("extension", m); + const files = declareExtensionFiles({ + token: String(m.json.token), + base: opts.base ?? "https://crawlproof.com", + who: `${actor.name || actor.email} (${actor.kind})`, + }); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body, { mode: 0o600 }); + if (json) { + out.write(JSON.stringify({ dir, token_id: m.json.id, prefix: m.json.prefix }, null, 2)); + return 0; + } + out.write(`wrote ${dir} for ${actor.email} (${actor.kind}), token ${String(m.json.prefix)}… id ${String(m.json.id)}`); + out.write(""); + out.write(` chrome --load-extension=${dir} --disable-extensions-except=${dir} …`); + out.write(` chrome-devtools-mcp --chromeArg=--load-extension=${dir} --chromeArg=--disable-extensions-except=${dir}`); + out.write(""); + out.write(`Revoke: crawlproof actors revoke ${actor.email} --token-id=${String(m.json.id)}`); + return 0; + } + + out.error(`unknown: crawlproof actors ${sub} (expected: list | add | token | revoke | extension)`); return 2; } diff --git a/lib/tracker/declareExtension.ts b/lib/tracker/declareExtension.ts new file mode 100644 index 0000000..09c882f --- /dev/null +++ b/lib/tracker/declareExtension.ts @@ -0,0 +1,76 @@ +// The "declare me" Chrome extension for an agent's (or a person's) browser. +// +// An unpacked Manifest V3 extension with one declarativeNetRequest rule: set +// `Crawlproof-Actor: ` on requests to /api/track and nothing +// else. Scoping the header to the beacon endpoint is the point. A blanket +// "extra header on every request" (Playwright extraHTTPHeaders, Puppeteer +// setExtraHTTPHeaders) hands the token to every site the agent visits, and any +// of them could replay it to pose as that agent. +// +// No dependencies, so both CLIs can bundle it. `crawlproof actors extension` +// writes these files; Chrome loads them with --load-extension=. + +export type ExtensionFiles = Record<"manifest.json" | "rules.json" | "README.txt", string>; + +/** "https://crawlproof.com/" -> "https://crawlproof.com". Throws on a non-http(s) base. */ +export function trackOrigin(base: string): string { + const u = new URL(base); + if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error(`not an http(s) URL: ${base}`); + return u.origin; +} + +export function declareExtensionFiles(input: { token: string; base: string; who: string }): ExtensionFiles { + if (!/^cpa_[A-Za-z0-9_-]{32,124}$/.test(input.token)) throw new Error("not a cpa_ token"); + const origin = trackOrigin(input.base); + const manifest = { + manifest_version: 3, + name: "CrawlProof declared actor", + version: "1.0.0", + description: `Declares this browser's visits as ${input.who} to the CrawlProof tracker. Adds one header to ${origin}/api/track requests only.`, + // WithHostAccess: modifyHeaders needs host access to the request URL and + // to the page that sends it, which can be any tracked site. + permissions: ["declarativeNetRequestWithHostAccess"], + host_permissions: [""], + declarative_net_request: { + rule_resources: [{ id: "declare", enabled: true, path: "rules.json" }], + }, + }; + const rules = [ + { + id: 1, + priority: 1, + action: { + type: "modifyHeaders", + requestHeaders: [{ header: "Crawlproof-Actor", operation: "set", value: input.token }], + }, + condition: { + // Left-anchored on the full origin + path: a lookalike host or a page + // whose own URL merely contains this string does not match. + urlFilter: `|${origin}/api/track`, + resourceTypes: ["xmlhttprequest", "ping", "other"], + }, + }, + ]; + const readme = [ + `CrawlProof declared actor: ${input.who}`, + "", + `Every page this browser loads that runs the CrawlProof tracker is counted as`, + `${input.who}. The token goes only to ${origin}/api/track.`, + "", + "Load it:", + " chrome --load-extension=$PWD --disable-extensions-except=$PWD ...", + " Puppeteer: args: [`--load-extension=${dir}`, `--disable-extensions-except=${dir}`]", + " Playwright: chromium.launchPersistentContext(profile, { args: [same two flags] })", + " chrome-devtools-mcp: --chromeArg=--load-extension= --chromeArg=--disable-extensions-except=", + "", + "Branded Google Chrome 137+ ignores --load-extension; use Chromium or Chrome for Testing.", + "rules.json holds the token: keep this folder private (chmod 700).", + "Revoke: crawlproof actors revoke --token-id=", + "", + ].join("\n"); + return { + "manifest.json": `${JSON.stringify(manifest, null, 2)}\n`, + "rules.json": `${JSON.stringify(rules, null, 2)}\n`, + "README.txt": readme, + }; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 2d65921..38fb04d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/crawlproof", - "version": "0.4.0", + "version": "0.5.0", "description": "What the fleet costs and what it returns: a live terminal dashboard over CrawlProof traffic, ad delivery and CoinPay banking.", "license": "MIT", "type": "module", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7a40c91..950ad63 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -17,7 +17,7 @@ import { FINANCE_DAYS, runDashboard } from "../../../cli/dashboard"; import { EMAIL_TRACKING_USAGE, runEmailTracking } from "../../../lib/emailTracking/cli"; import { ACTORS_USAGE, runActors } from "../../../lib/tracker/actorsCli"; -export const VERSION = "0.4.0"; +export const VERSION = "0.5.0"; type Args = { command: string; @@ -420,7 +420,7 @@ export async function main(argv: string[]): Promise { return await runActors(args.positional, args.flags, (method, path, body) => apiCall(args, method, path, body), { write: (line: string) => process.stdout.write(`${line}\n`), error: (line: string) => console.error(line), - }); + }, { base: apiBase(args) }); case "email-tracking": return await runEmailTracking(args.positional, args.flags, (method, path) => apiCall(args, method, path), { write: (line: string) => process.stdout.write(`${line}\n`), diff --git a/tests/tracker-declare-extension.test.ts b/tests/tracker-declare-extension.test.ts new file mode 100644 index 0000000..020f6b6 --- /dev/null +++ b/tests/tracker-declare-extension.test.ts @@ -0,0 +1,104 @@ +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { declareExtensionFiles, trackOrigin } from "@/lib/tracker/declareExtension"; +import { runActors } from "@/lib/tracker/actorsCli"; + +// The extension is how an agent's browser declares itself without a code +// change. What has to hold: the token goes to the beacon endpoint and nowhere +// else, and the folder holding it is private. + +const TOKEN = "cpa_" + "A".repeat(43); + +describe("declareExtensionFiles", () => { + const files = declareExtensionFiles({ token: TOKEN, base: "https://crawlproof.com/", who: "riotcoder (agent)" }); + const rules = JSON.parse(files["rules.json"]); + const manifest = JSON.parse(files["manifest.json"]); + + it("sets the header only on /api/track, left-anchored", () => { + expect(rules).toHaveLength(1); + expect(rules[0].condition.urlFilter).toBe("|https://crawlproof.com/api/track"); + expect(rules[0].action.requestHeaders).toEqual([{ header: "Crawlproof-Actor", operation: "set", value: TOKEN }]); + }); + + it("is a Manifest V3 declarativeNetRequest extension with no other powers", () => { + expect(manifest.manifest_version).toBe(3); + expect(manifest.permissions).toEqual(["declarativeNetRequestWithHostAccess"]); + expect(manifest.background).toBeUndefined(); + expect(manifest.content_scripts).toBeUndefined(); + }); + + it("follows a self-hosted base", () => { + const self = JSON.parse(declareExtensionFiles({ token: TOKEN, base: "http://localhost:3000", who: "x" })["rules.json"]); + expect(self[0].condition.urlFilter).toBe("|http://localhost:3000/api/track"); + }); + + it("refuses a non-token and a non-http base", () => { + expect(() => declareExtensionFiles({ token: "crp_" + "A".repeat(43), base: "https://crawlproof.com", who: "x" })).toThrow(); + expect(() => trackOrigin("file:///etc/passwd")).toThrow(); + }); +}); + +describe("crawlproof actors extension", () => { + let dir = ""; + afterEach(() => dir && rmSync(dir, { recursive: true, force: true })); + + it("mints a token for the actor and writes a private folder", async () => { + dir = mkdtempSync(join(tmpdir(), "cp-ext-")); + const out = join(dir, "declare"); + const calls: [string, string, unknown][] = []; + const call = async (method: string, path: string, body?: Record) => { + calls.push([method, path, body]); + if (method === "GET") { + return { status: 200, json: { actors: [{ id: "a2", email: "riotcoder@profullstack.com", name: "riotcoder", kind: "agent", email_verified: true, visibility: "private", tokens: [], last30: { events: 0, pageviews: 0, contradictions: 0, sites: 0 } }] } }; + } + return { status: 201, json: { id: "t9", token: TOKEN, prefix: "cpa_AAAA" } }; + }; + const lines: string[] = []; + const code = await runActors( + ["extension", "riotcoder@profullstack.com"], + { out }, + call, + { write: (l) => lines.push(l), error: (l) => lines.push(`ERR ${l}`) }, + { base: "https://crawlproof.com" }, + ); + expect(code).toBe(0); + expect(calls[1]).toEqual(["POST", "/api/tracker/v1/actors/a2/tokens", { label: "browser extension" }]); + expect(JSON.parse(readFileSync(join(out, "rules.json"), "utf8"))[0].action.requestHeaders[0].value).toBe(TOKEN); + expect(statSync(out).mode & 0o777).toBe(0o700); + expect(statSync(join(out, "rules.json")).mode & 0o777).toBe(0o600); + expect(lines.join("\n")).toContain("--load-extension="); + }); + + it("asks for an actor it can find instead of guessing", async () => { + const call = async () => ({ status: 200, json: { actors: [] } }); + const errors: string[] = []; + expect(await runActors(["extension", "nobody@example.com"], {}, call, { write: () => {}, error: (l) => errors.push(l) })).toBe(2); + expect(errors[0]).toContain("usage: crawlproof actors extension"); + }); +}); + +describe("actors revoke --token-id (regression)", () => { + it("revokes one token by id, and --token-id never becomes the API key", async () => { + const { apiToken, parseArgs } = await import("@/packages/cli/src/cli"); + const args = parseArgs(["actors", "revoke", "riotcoder@profullstack.com", "--token-id=t9"]); + // --token is the CLI-wide API key override; the token id must not land there. + expect(args.flags.token).toBeUndefined(); + const prev = process.env.CRAWLPROOF_TOKEN; + process.env.CRAWLPROOF_TOKEN = "crp_from_env_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"; + expect(apiToken(args)).toBe("crp_from_env_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"); + if (prev === undefined) delete process.env.CRAWLPROOF_TOKEN; + else process.env.CRAWLPROOF_TOKEN = prev; + + const calls: [string, string][] = []; + const call = async (method: string, path: string) => { + calls.push([method, path]); + return method === "GET" + ? { status: 200, json: { actors: [{ id: "a2", email: "riotcoder@profullstack.com", name: "", kind: "agent", email_verified: true, visibility: "private", tokens: [], last30: { events: 0, pageviews: 0, contradictions: 0, sites: 0 } }] } } + : { status: 200, json: { ok: true } }; + }; + expect(await runActors(args.positional, args.flags, call, { write: () => {}, error: () => {} })).toBe(0); + expect(calls[1]).toEqual(["DELETE", "/api/tracker/v1/actors/a2/tokens?token=t9"]); + }); +}); diff --git a/tests/tracker-declared-actors.test.ts b/tests/tracker-declared-actors.test.ts index a507880..9820c54 100644 --- a/tests/tracker-declared-actors.test.ts +++ b/tests/tracker-declared-actors.test.ts @@ -324,6 +324,6 @@ describe("runActors (shared by both CLIs)", () => { it("is wired into the published CLI, not only the in-repo one", () => { const src = readFileSync("packages/cli/src/cli.ts", "utf8"); expect(src).toContain('case "actors":'); - expect(src).toContain('export const VERSION = "0.4.0";'); + expect(src).toContain('export const VERSION = "0.5.0";'); }); }); From 70009f091baa8873a4f7b9708ac133c4ea6cf8b7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 4 Oct 2026 15:54:55 +0000 Subject: [PATCH 2/2] test: build the fake API key instead of a credential-shaped literal ThreatCrush flagged the regression test's fixture as a hardcoded credential. It never was one; constructing it the way the rest of the suite does keeps the scanner signal clean without a dismissal. Co-Authored-By: Claude Opus 5.5 --- tests/tracker-declare-extension.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/tracker-declare-extension.test.ts b/tests/tracker-declare-extension.test.ts index 020f6b6..8ff87c3 100644 --- a/tests/tracker-declare-extension.test.ts +++ b/tests/tracker-declare-extension.test.ts @@ -86,8 +86,9 @@ describe("actors revoke --token-id (regression)", () => { // --token is the CLI-wide API key override; the token id must not land there. expect(args.flags.token).toBeUndefined(); const prev = process.env.CRAWLPROOF_TOKEN; - process.env.CRAWLPROOF_TOKEN = "crp_from_env_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"; - expect(apiToken(args)).toBe("crp_from_env_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"); + const FAKE_KEY = "crp_" + "x".repeat(40); // fixture, not a credential + process.env.CRAWLPROOF_TOKEN = FAKE_KEY; + expect(apiToken(args)).toBe(FAKE_KEY); if (prev === undefined) delete process.env.CRAWLPROOF_TOKEN; else process.env.CRAWLPROOF_TOKEN = prev;