Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ threatcrush init --offline # Same, without signing in (scripts, CI, con

Config lives at `/etc/threatcrush/threatcrushd.conf` with module configs in `/etc/threatcrush/threatcrushd.conf.d/`.

### Usage counts

Each `threatcrush ai-scan` / `ai-scan-run` sends one anonymous count to the API
(`THREATCRUSH_API_URL`, default threatcrush.com): command, CLI version, OS, Node
version, CI or not, a random install ID (`~/.threatcrush/usage-ping.json`), and
probe/finding counts and run time. Never the target, prompts, responses or
finding text. Turn it off with `THREATCRUSH_TELEMETRY=0` or `DO_NOT_TRACK=1`.
Requests to our API also carry the user agent `threatcrush/<version>`.

## Cloud dashboard

The daemon reports to your ThreatCrush dashboard once the machine is logged in and linked to a server there:
Expand Down
11 changes: 11 additions & 0 deletions apps/cli/src/commands/ai-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { RunResult } from '../core/run-result.js';
import { runAiScan, httpSender } from '../aiscan/runner.js';
import { selectProbes, type ProbeCategory } from '../aiscan/probes.js';
import { runMosh } from '../aiscan/mosh.js';
import { sendUsagePing } from '../core/usage-ping.js';

export interface AiScanCliOptions {
categories?: string; // comma list
Expand Down Expand Up @@ -46,6 +47,7 @@ export async function aiScanCommand(url: string, opts: AiScanCliOptions): Promis
const probes = selectProbes(categories);
logger.info(`Probing ${chalk.white(url)} with ${probes.length} check(s)...\n`);
const spinner = ora({ text: 'Running probes...', color: 'green' }).start();
const t0 = Date.now();

const result = await runAiScan(url, send, {
categories,
Expand All @@ -55,6 +57,10 @@ export async function aiScanCommand(url: string, opts: AiScanCliOptions): Promis
},
});
spinner.stop();
await sendUsagePing({
event: 'ai-scan', probes: probes.length, findings: result.findings.length,
severity: result.severity_summary, duration_ms: Date.now() - t0, failed: Boolean(result.error),
});

if (opts.json) {
console.log(JSON.stringify(result, null, 2));
Expand Down Expand Up @@ -93,7 +99,12 @@ export async function aiScanMoshCommand(file: string, args: string[]): Promise<v
return;
}

const t0 = Date.now();
const res = await runMosh(source, { argv: args, out: (s) => console.log(' ' + s) });
await sendUsagePing({
event: 'ai-scan-run', scans: res.scans.length, findings: res.scans.reduce((n, s) => n + s.findings.length, 0),
duration_ms: Date.now() - t0, failed: Boolean(res.failed),
});
if (res.failed) {
console.log();
console.log(` ${chalk.red('✗ script failed:')} ${res.failed}`);
Expand Down
68 changes: 68 additions & 0 deletions apps/cli/src/core/__tests__/usage-ping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { installId, sendUsagePing, usagePingDisabled } from "../usage-ping.js";

const dirs: string[] = [];
const tmp = () => { const d = mkdtempSync(join(tmpdir(), "tc-ping-")); dirs.push(d); return d; };
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); });

function capture() {
const calls: { url: string; body: Record<string, unknown> }[] = [];
const fetchImpl = (async (url: string, init?: RequestInit) => {
calls.push({ url, body: JSON.parse(String(init?.body)) });
return new Response("{}", { status: 202 });
}) as unknown as typeof fetch;
return { calls, fetchImpl };
}

describe("usage ping", () => {
it("is off with THREATCRUSH_TELEMETRY=0/off/false/no or DO_NOT_TRACK=1", () => {
for (const v of ["0", "off", "false", "no", "OFF"]) expect(usagePingDisabled({ THREATCRUSH_TELEMETRY: v })).toBe(true);
expect(usagePingDisabled({ DO_NOT_TRACK: "1" })).toBe(true);
expect(usagePingDisabled({ DO_NOT_TRACK: "0" })).toBe(false);
expect(usagePingDisabled({})).toBe(false);
});

it("sends nothing when disabled", async () => {
const { calls, fetchImpl } = capture();
const ok = await sendUsagePing({ event: "ai-scan", findings: 1, duration_ms: 5 }, { fetchImpl, dir: tmp(), env: { DO_NOT_TRACK: "1" } });
expect(ok).toBe(false);
expect(calls).toHaveLength(0);
});

it("keeps one install id and prints the notice only the first time", async () => {
const dir = tmp(), notices: string[] = [];
const { calls, fetchImpl } = capture();
const ping = { event: "ai-scan" as const, probes: 12, findings: 2, severity: { high: 2 }, duration_ms: 40 };
await sendUsagePing(ping, { fetchImpl, dir, env: {}, notice: (s) => notices.push(s) });
await sendUsagePing(ping, { fetchImpl, dir, env: {}, notice: (s) => notices.push(s) });
expect(notices).toHaveLength(1);
expect(notices[0]).toMatch(/THREATCRUSH_TELEMETRY=0/);
expect(calls).toHaveLength(2);
expect(calls[0].url).toMatch(/\/api\/telemetry$/);
expect(calls[0].body.install_id).toBe(calls[1].body.install_id);
expect(calls[0].body.install_id).toBe(JSON.parse(readFileSync(join(dir, "usage-ping.json"), "utf-8")).install_id);
});

it("carries counts and environment, never the target", async () => {
const { calls, fetchImpl } = capture();
await sendUsagePing({ event: "ai-scan", probes: 3, findings: 0, duration_ms: 9 }, { fetchImpl, dir: tmp(), env: { CI: "true" }, notice: () => {} });
const b = calls[0].body;
expect(Object.keys(b).sort()).toEqual(["arch", "ci", "duration_ms", "event", "findings", "install_id", "node", "platform", "probes", "version"]);
expect(b.ci).toBe(true);
});

it("never throws when the network fails", async () => {
const fetchImpl = (async () => { throw new Error("offline"); }) as unknown as typeof fetch;
await expect(sendUsagePing({ event: "ai-scan-run", scans: 1, findings: 0, duration_ms: 1 }, { fetchImpl, dir: tmp(), env: {}, notice: () => {} })).resolves.toBe(false);
});

it("reuses an id already on disk", () => {
const dir = tmp();
const a = installId(dir), b = installId(dir);
expect(a.fresh).toBe(true);
expect(b).toEqual({ id: a.id, fresh: false });
});
});
90 changes: 90 additions & 0 deletions apps/cli/src/core/usage-ping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { apiUrl, CLI_CONFIG_DIR } from './cli-config.js';
import type { Severity } from './run-result.js';
import { PKG_VERSION } from './version.js';

// One anonymous usage ping per ai-scan run, so threatcrush.com can count how
// often the scanner is used. ai-scan runs on the user's machine and otherwise
// sends nothing home. (Error reporting is a separate thing: core/telemetry.ts.)
//
// What is sent: the command, CLI version, OS, Node version, whether it ran in
// CI, a random per-machine install id, and counts (probes, findings by
// severity, scans in a .mosh script, duration). What is never sent: the target
// URL, headers, prompts, responses or finding text.
//
// Off with THREATCRUSH_TELEMETRY=0 (or off/false/no) or DO_NOT_TRACK=1. The
// first run prints a one-line notice saying so.

const STATE_FILE = 'usage-ping.json';

export interface UsagePing {
event: 'ai-scan' | 'ai-scan-run';
probes?: number;
scans?: number;
findings: number;
severity?: Partial<Record<Severity, number>>;
duration_ms: number;
failed?: boolean;
}

export function usagePingDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
const v = (env.THREATCRUSH_TELEMETRY ?? '').trim().toLowerCase();
if (['0', 'off', 'false', 'no'].includes(v)) return true;
const dnt = (env.DO_NOT_TRACK ?? '').trim().toLowerCase();
return dnt !== '' && dnt !== '0' && dnt !== 'false';
}

/** The random install id, created on first use; `fresh` is true that once (for the notice). */
export function installId(dir = CLI_CONFIG_DIR): { id: string; fresh: boolean } {
const file = join(dir, STATE_FILE);
try {
const j = JSON.parse(readFileSync(file, 'utf-8')) as { install_id?: string };
if (j.install_id) return { id: j.install_id, fresh: false };
} catch {}
const id = randomUUID();
try {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(file, JSON.stringify({ install_id: id, created_at: new Date().toISOString() }) + '\n');
} catch {}
return { id, fresh: true };
}

export function pingBody(p: UsagePing, id: string, env: NodeJS.ProcessEnv = process.env) {
return {
...p,
version: PKG_VERSION,
platform: process.platform,
arch: process.arch,
node: process.version,
ci: Boolean(env.CI || env.GITHUB_ACTIONS),
install_id: id,
};
}

/** Best effort: never throws, gives up after `timeoutMs` so it cannot hold the CLI open. */
export async function sendUsagePing(
p: UsagePing,
opts: { fetchImpl?: typeof fetch; dir?: string; timeoutMs?: number; notice?: (s: string) => void; env?: NodeJS.ProcessEnv } = {},
): Promise<boolean> {
const env = opts.env ?? process.env;
if (usagePingDisabled(env)) return false;
try {
const { id, fresh } = installId(opts.dir);
if (fresh) {
(opts.notice ?? ((s) => process.stderr.write(s + '\n')))(
'threatcrush: sends an anonymous usage count per ai-scan run (no targets, no findings text). Turn off with THREATCRUSH_TELEMETRY=0.',
);
}
const res = await (opts.fetchImpl ?? fetch)(`${apiUrl()}/api/telemetry`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(pingBody(p, id, env)),
signal: AbortSignal.timeout(opts.timeoutMs ?? 2_000),
});
return res.ok;
} catch {
return false;
}
}
64 changes: 64 additions & 0 deletions apps/web/src/app/api/telemetry/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const inserted: unknown[] = [];
let insertError: { message: string } | null = null;

vi.mock("@/lib/supabase", () => ({
getSupabaseAdmin: () => ({
from: (table: string) => ({
insert: async (row: unknown) => {
inserted.push({ table, row });
return { error: insertError };
},
}),
}),
}));

import { POST } from "@/app/api/telemetry/route";
import { parsePing } from "@/lib/usage-ping";

const req = (body: string) =>
new Request("http://localhost/api/telemetry", { method: "POST", body }) as unknown as import("next/server").NextRequest;

const PING = {
event: "ai-scan", probes: 12, findings: 2, severity: { high: 2, bogus: 9 }, duration_ms: 812,
version: "0.13.53", platform: "linux", arch: "x64", node: "v24.22.1", ci: false,
install_id: "0b9c3f2e-6a1d-4c55-9f3e-2d4b8a7c1e90",
};

describe("POST /api/telemetry", () => {
beforeEach(() => { inserted.length = 0; insertError = null; });

it("stores a valid ping and keeps only documented fields", async () => {
const res = await POST(req(JSON.stringify({ ...PING, target: "https://secret.example/llm", ip: "1.2.3.4" })));
expect(res.status).toBe(202);
expect(inserted).toHaveLength(1);
const { table, row } = inserted[0] as { table: string; row: Record<string, unknown> };
expect(table).toBe("cli_usage_pings");
expect(row).not.toHaveProperty("target");
expect(row).not.toHaveProperty("ip");
expect(row.severity).toEqual({ high: 2 });
expect(row.findings).toBe(2);
});

it("rejects unknown events, junk and oversize bodies without storing", async () => {
for (const body of [JSON.stringify({ ...PING, event: "scan" }), "not json", "[]", "x".repeat(5000), ""]) {
expect((await POST(req(body))).status).toBe(400);
}
expect(inserted).toHaveLength(0);
});

it("drops malformed strings and clamps numbers", () => {
const row = parsePing({ ...PING, version: "<script>", install_id: "nope", probes: -1, duration_ms: 1e12, ci: "yes" })!;
expect(row.version).toBeNull();
expect(row.install_id).toBeNull();
expect(row.probes).toBeNull();
expect(row.duration_ms).toBe(1_000_000);
expect(row.ci).toBe(false);
});

it("answers 500 when the insert fails", async () => {
insertError = { message: "relation does not exist" };
expect((await POST(req(JSON.stringify(PING)))).status).toBe(500);
});
});
25 changes: 25 additions & 0 deletions apps/web/src/app/api/telemetry/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse, type NextRequest } from "next/server";
import { getSupabaseAdmin } from "@/lib/supabase";
import { MAX_BODY, parsePing } from "@/lib/usage-ping";

// POST /api/telemetry: stores one anonymous CLI usage ping (see lib/usage-ping.ts).
// No IP or user is recorded.

export async function POST(request: NextRequest) {
const text = await request.text().catch(() => "");
if (!text || text.length > MAX_BODY) return NextResponse.json({ error: "bad ping" }, { status: 400 });
let body: unknown;
try {
body = JSON.parse(text);
} catch {
return NextResponse.json({ error: "bad ping" }, { status: 400 });
}
const row = parsePing(body);
if (!row) return NextResponse.json({ error: "bad ping" }, { status: 400 });
const { error } = await getSupabaseAdmin().from("cli_usage_pings").insert(row);
if (error) {
console.error("[telemetry] insert failed", error.message);
return NextResponse.json({ error: "not stored" }, { status: 500 });
}
return NextResponse.json({ ok: true }, { status: 202 });
}
12 changes: 11 additions & 1 deletion apps/web/src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function PrivacyPage() {
<h1 className="mt-3 text-4xl font-bold text-tc-text">
Privacy <span className="text-tc-green glow-green">Policy</span>
</h1>
<p className="mt-2 text-sm text-tc-text-dim">Last updated: September 25, 2026</p>
<p className="mt-2 text-sm text-tc-text-dim">Last updated: October 28, 2026</p>
</header>

<div className="space-y-8 text-tc-text">
Expand Down Expand Up @@ -57,6 +57,16 @@ export default function PrivacyPage() {
unlinked daemon sends none of this. The CLI keeps your sign-in tokens on the machine
in <code className={code}>~/.threatcrush/config.json</code>.
</li>
<li>
<span className="text-tc-text">CLI usage counts.</span> Each{" "}
<code className={code}>threatcrush ai-scan</code>{" "}run sends one anonymous count: the
command, CLI version, operating system, Node version, whether it ran in CI, a random
install ID kept in <code className={code}>~/.threatcrush/usage-ping.json</code>, and the
number of probes, findings by severity and the run time. It never includes the target,
prompts, responses or finding text, and is not tied to your account. Turn it off with{" "}
<code className={code}>THREATCRUSH_TELEMETRY=0</code>{" "}or{" "}
<code className={code}>DO_NOT_TRACK=1</code>.
</li>
<li>
<span className="text-tc-text">GitHub App.</span> If you install the ThreatCrush
GitHub App, we read the file list and file contents of the repositories you grant it
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/lib/usage-ping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Anonymous CLI usage pings (apps/cli/src/core/usage-ping.ts): one per
// ai-scan run, counts only. Every field is checked against a narrow shape and
// anything else is dropped, so the table can only ever hold what the CLI
// documents sending.

const EVENTS = new Set(["ai-scan", "ai-scan-run"]);
const SEVERITIES = ["critical", "high", "medium", "low", "info"] as const;
export const MAX_BODY = 4096;

const count = (v: unknown) =>
typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.min(Math.floor(v), 1_000_000) : null;
const short = (v: unknown, re: RegExp) => (typeof v === "string" && re.test(v) ? v : null);

export function parsePing(body: unknown) {
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
const b = body as Record<string, unknown>;
if (typeof b.event !== "string" || !EVENTS.has(b.event)) return null;
const severity: Record<string, number> = {};
if (b.severity && typeof b.severity === "object") {
for (const k of SEVERITIES) {
const n = count((b.severity as Record<string, unknown>)[k]);
if (n) severity[k] = n;
}
}
return {
event: b.event,
version: short(b.version, /^[0-9A-Za-z.+-]{1,32}$/),
platform: short(b.platform, /^[a-z0-9]{1,16}$/),
arch: short(b.arch, /^[a-z0-9_]{1,16}$/),
node: short(b.node, /^v?[0-9.]{1,16}$/),
ci: b.ci === true,
install_id: short(b.install_id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i),
probes: count(b.probes),
scans: count(b.scans),
findings: count(b.findings) ?? 0,
severity,
duration_ms: count(b.duration_ms),
failed: b.failed === true,
};
}
2 changes: 2 additions & 0 deletions apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ const RATE_RULES: RateRule[] = [
{ prefix: "/api/credits/invites/accept", limit: 10, windowMs: 60_000 },
{ prefix: "/api/hooks/", limit: 60, windowMs: 60_000 },
{ prefix: "/api/scan", limit: 20, windowMs: 60_000 },
// Anonymous CLI usage pings: one per ai-scan run, so 30/min per IP is generous.
{ prefix: "/api/telemetry", limit: 30, windowMs: 60_000 },
{ prefix: "/api/modules/fetch-meta", limit: 20, windowMs: 60_000 },
{ prefix: "/api/funding/", limit: 30, windowMs: 60_000 },
// Public and unauthenticated: IP geolocation for the TUI and desktop maps,
Expand Down
2 changes: 1 addition & 1 deletion docs/SELF_HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ sudo threatcrush install-service
- **Normalized detections only** by default -- no raw logs are stored
- All data stays in your Supabase/Postgres instance
- The agent sends only structured detection events (severity, title, source IP)
- No telemetry phones home unless you opt in
- No telemetry phones home unless you opt in. The CLI's anonymous ai-scan usage count goes to `THREATCRUSH_API_URL`, so a self-hosted install sends it to your own server, never to threatcrush.com (off with `THREATCRUSH_TELEMETRY=0`)

## Updating

Expand Down
Loading
Loading