Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/security-recommendations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Add `clerk security`: `audit` grades an instance against Clerk's security recommendations, `fix` applies them as one config patch, and `checks` lists the catalog. Agent mode gets the report as JSON with the exact patch per finding.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ Commands:
link [options] Link this project to a Clerk application
mcp Manage the Clerk remote MCP server connection for AI editors and CLIs
open Open Clerk resources in your browser
security Audit an instance against Clerk's security recommendations
telemetry Control CLI usage telemetry (status, disable, enable)
unlink [options] Unlink this project from its Clerk application
update [options] Update the Clerk CLI to the latest version
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { registerTelemetry } from "./commands/telemetry/index.ts";
import { registerToggles } from "./commands/toggles/index.ts";
import { registerApi } from "./commands/api/index.ts";
import { registerDoctor } from "./commands/doctor/index.ts";
import { registerSecurity } from "./commands/security/index.ts";
import { registerMcp } from "./commands/mcp/index.ts";
import { registerSwitchEnv } from "./commands/switch-env/index.ts";
import { registerCompletion } from "./commands/completion/index.ts";
Expand Down Expand Up @@ -77,6 +78,7 @@ const registrants: CommandRegistrant[] = [
registerToggles,
registerApi,
registerDoctor,
registerSecurity,
registerMcp,
registerSwitchEnv,
registerCompletion,
Expand Down
21 changes: 16 additions & 5 deletions packages/cli-core/src/commands/completion/__complete.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CommandUnknownOpts, Option } from "@commander-js/extra-typings";
import { CHECKS } from "../security/catalog.ts";
import { KNOWN_DASHBOARD_PATHS } from "../open/dashboard-paths.ts";

const DIRECTIVE = {
Expand Down Expand Up @@ -52,6 +53,12 @@ const KNOWN_OPTION_VALUES: Record<string, Completion[]> = {
{ name: "latest", description: "Latest stable release" },
{ name: "canary", description: "Latest canary (pre-release) build" },
],
"--factors": [
{ name: "authenticator", description: "Authenticator app (TOTP)" },
{ name: "backup-code", description: "Backup codes" },
{ name: "sms", description: "SMS code" },
{ name: "authenticator,backup-code", description: "Authenticator app and backup codes" },
],
"--for": [
{ name: "orgs", description: "Organizations only" },
{ name: "users", description: "Users only" },
Expand All @@ -70,6 +77,7 @@ const KNOWN_POSITIONAL_COMPLETIONS: Record<string, Completion[]> = {
name: path,
description: "Dashboard subpath",
})),
"security fix": CHECKS.map((check) => ({ name: check.id, description: check.title })),
};

/**
Expand Down Expand Up @@ -211,11 +219,14 @@ function completeArguments(
consumedCount: number,
): CompletionResult {
const registeredArgs = cmd.registeredArguments;
if (consumedCount >= registeredArgs.length) {
return EMPTY_NO_FILE;
}

const arg = registeredArgs[consumedCount];
const last = registeredArgs.at(-1);
const arg =
consumedCount < registeredArgs.length
? registeredArgs[consumedCount]
: last?.variadic
? last
: undefined;
if (!arg) return EMPTY_NO_FILE;

// Prefer strict Commander choices when available.
if (arg?.argChoices) {
Expand Down
3 changes: 3 additions & 0 deletions packages/cli-core/src/commands/config/apply-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface ApplyPatchOptions {
warning?: string;
/** Pre-fetched current config; skips the extra GET when caller already has it. */
currentConfig?: Record<string, unknown>;
/** Receives the response body: the written document, or the projection under `--dry-run`. */
onWritten?: (body: Record<string, unknown>) => void;
}

/** Fetch + diff + confirm + PATCH, matching `clerk config patch` semantics. */
Expand Down Expand Up @@ -67,6 +69,7 @@ export async function applyConfigPatch(opts: ApplyPatchOptions): Promise<boolean
);

log.debug(`config: ${JSON.stringify(result.body)}`);
opts.onWritten?.(result.body);
if (dryRun) {
log.success("[dry-run] Validation passed — no changes applied");
} else {
Expand Down
335 changes: 335 additions & 0 deletions packages/cli-core/src/commands/security/README.md

Large diffs are not rendered by default.

196 changes: 196 additions & 0 deletions packages/cli-core/src/commands/security/audit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { _setConfigDir, setProfile } from "../../lib/config.ts";
import { useCaptureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts";
import { INSECURE_CONFIG, INSECURE_OAUTH_CONFIG } from "./fixtures.ts";
import type { AuditOptions, AuditReport } from "./types.ts";

mock.module("../../lib/credential-store.ts", () => credentialStoreStubs);
mock.module("../../lib/git.ts", () => gitStubs);
mock.module("../../lib/spinner.ts", () => ({
intro: () => {},
outro: () => {},
pausedOutro: () => {},
bar: () => {},
withGutter: async (
_title: string,
fn: (controls: { setNextSteps: (steps: readonly string[]) => void }) => Promise<unknown>,
) => fn({ setNextSteps: () => {} }),
withSpinner: async (_msg: string, fn: () => Promise<unknown>) => fn(),
}));

const MOCK_APP = {
application_id: "app_1",
name: "My App",
instances: [
{ instance_id: "ins_dev", environment_type: "development" },
{ instance_id: "ins_prod", environment_type: "production" },
],
};

describe("security audit", () => {
const originalEnv = { ...process.env };
const originalFetch = globalThis.fetch;
let tempDir: string;
let logSpy: ReturnType<typeof spyOn>;
let errorSpy: ReturnType<typeof spyOn>;
const captured = useCaptureLog();

function serve(config: Record<string, unknown>) {
stubFetch(async (input) => {
const url = input.toString();
if (url.includes("/config")) return new Response(JSON.stringify(config), { status: 200 });
if (url.includes("/v1/platform/applications/app_1")) {
return new Response(JSON.stringify(MOCK_APP), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
}

async function link() {
await setProfile(process.cwd(), {
workspaceId: "org_1",
appId: "app_1",
instances: { development: "ins_dev", production: "ins_prod" },
});
}

async function run(options: AuditOptions = {}) {
const { securityAudit } = await import("./audit.ts");
return securityAudit(options);
}

function report(): AuditReport {
return JSON.parse(captured.out) as AuditReport;
}

beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "clerk-security-audit-test-"));
_setConfigDir(tempDir);
process.env.CLERK_PLATFORM_API_KEY = "test_key";
process.env.CLERK_PLATFORM_API_URL = "https://test-api.clerk.com";
process.env.CLERK_MODE = "human";
delete process.env.CLERK_SECRET_KEY;
logSpy = spyOn(console, "log").mockImplementation(() => {});
errorSpy = spyOn(console, "error").mockImplementation(() => {});
serve(INSECURE_CONFIG);
});

afterEach(async () => {
_setConfigDir(undefined);
process.env = { ...originalEnv };
globalThis.fetch = originalFetch;
logSpy.mockRestore();
errorSpy.mockRestore();
await rm(tempDir, { recursive: true, force: true });
});

test("errors when no profile is linked", async () => {
await expect(run()).rejects.toThrow("No Clerk project linked");
});

test("refuses an accountless target", async () => {
process.env.CLERK_SECRET_KEY = "sk_test_local";
await expect(run()).rejects.toThrow("claimed application");
});

test("emits the JSON envelope with --json", async () => {
await link();
await run({ json: true });

const parsed = report();
expect(parsed.instance).toEqual({
appId: "app_1",
instanceId: "ins_dev",
environmentType: "development",
label: "app_1 (development)",
});
expect(parsed.score.grade).toBe("F");
expect(parsed.score.hasCriticalGap).toBe(true);
expect(parsed.fixCommand).toStartWith("clerk security fix ");
expect(parsed.fixCommand).not.toContain("--yes");
expect(parsed.fixCommand).toContain("user-lockout");
expect(parsed.fixCommand).not.toContain("block-email-subaddresses");

const lockout = parsed.findings.find((f) => f.id === "user-lockout")!;
expect(lockout.status).toBe("unmet");
expect(lockout.patch).toEqual({ auth_attack_protection: { user_lockout: { enabled: true } } });
expect(lockout.remedy).toBe(
"Run `clerk security fix user-lockout --app app_1 --instance ins_dev`.",
);
expect(lockout.dashboardUrl).toContain("/apps/app_1/instances/ins_dev/user-authentication");
expect(lockout.docsUrl).toBe("https://clerk.com/docs/guides/secure/user-lockout");

const mfa = parsed.findings.find((f) => f.id === "mfa")!;
expect(mfa.patch).toBeNull();
expect(mfa.features).toEqual(["app:mfa_totp", "app:mfa_phone_code", "app:mfa_backup_code"]);
expect(mfa.remedy).toContain(
"clerk security fix mfa --factors authenticator,backup-code --app app_1 --instance ins_dev",
);
expect(mfa.decision?.flag).toBe("factors");
});

test("agent mode forces JSON, rewrites docs URLs, and adds --yes to the fix command", async () => {
process.env.CLERK_MODE = "agent";
await link();
await run();

const parsed = report();
expect(parsed.fixCommand).toEndWith(" --yes");
expect(parsed.findings[0]!.docsUrl).toEndWith(".md");
expect(captured.err).not.toContain("Grade");
});

test("orders findings by severity then status", async () => {
await link();
await run({ json: true });
const statuses = report().findings.map((f) => `${f.severity}:${f.status}`);
const firstRecommended = statuses.findIndex((s) => s.startsWith("recommended"));
expect(statuses.slice(0, firstRecommended).every((s) => s.startsWith("critical"))).toBe(true);
const recommended = statuses.filter((s) => s.startsWith("recommended"));
expect(recommended.indexOf("recommended:blocked")).toBeGreaterThan(
recommended.lastIndexOf("recommended:unmet"),
);
});

test("renders a grouped human report", async () => {
await link();
await run();
expect(captured.err).toContain("Grade F");
expect(captured.err).toContain("Critical");
expect(captured.err).toContain("Brute-force lockout");
expect(captured.err).toContain("user-lockout");
expect(captured.err).toContain("blocked:");
expect(captured.err).toContain("Disabled");
expect(captured.err).toContain("(asks --factors)");
expect(captured.err).toContain("blocked:");
expect(captured.out).toBe("");
});

test("resolves the environment type for a literal instance id", async () => {
serve(INSECURE_OAUTH_CONFIG);
await link();
await run({ json: true, instance: "ins_prod" });
const parsed = report();
expect(parsed.instance.environmentType).toBe("production");
expect(parsed.findings.some((f) => f.id === "oauth-custom-credentials")).toBe(true);
});

test("rejects a literal instance id the application does not own", async () => {
await link();
await expect(run({ json: true, instance: "ins_other" })).rejects.toThrow(
"does not belong to application app_1",
);
});

test("targets an app directly with --app", async () => {
await run({ json: true, app: "app_1", instance: "prod" });
expect(report().instance.instanceId).toBe("ins_prod");
expect(report().fixCommand).toContain(" --app app_1 --instance ins_prod");
for (const finding of report().findings.filter((f) => f.remedy.includes("clerk "))) {
expect(finding.remedy).toContain(" --app app_1 --instance ins_prod");
}
});
});
32 changes: 32 additions & 0 deletions packages/cli-core/src/commands/security/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { log } from "../../lib/log.ts";
import { NEXT_STEPS } from "../../lib/next-steps.ts";
import { intro, outro } from "../../lib/spinner.ts";
import { isAgent } from "../../mode.ts";
import { formatReportHuman, formatReportJson } from "./format.ts";
import { loadAudit } from "./load.ts";
import type { AuditOptions } from "./types.ts";

export async function securityAudit(options: AuditOptions = {}): Promise<void> {
const json = Boolean(options.json) || isAgent();

if (!json) intro("Security audit");
const { report } = await loadAudit(options);

if (json) {
log.data(formatReportJson(report));
return;
}

log.blank();
for (const line of formatReportHuman(report)) log.info(line);

const flags = `${options.app ? ` --app ${options.app}` : ""}${options.instance ? ` --instance ${options.instance}` : ""}`;
await outro(
report.fixCommand
? [
`Run \`clerk security fix${flags}\` to choose which recommendations to apply, or \`clerk security fix --all${flags}\` for every critical and recommended one`,
...NEXT_STEPS.SECURITY_AUDIT,
]
: NEXT_STEPS.SECURITY_AUDIT,
);
}
Loading
Loading