diff --git a/package.json b/package.json index b2321a8..4cbe37d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@enkryptify/cli", - "version": "0.3.5", + "version": "0.4.0", "bin": { "ek": "./dist/cli.js" }, diff --git a/src/cli.ts b/src/cli.ts index 08c1242..ef1d747 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,7 +43,8 @@ if (isCompletion) { } setupTerminalCleanup(); -await analytics.init(); +// "scan" needs no authentication, so skip the keychain lookup to avoid a password prompt. +await analytics.init({ skipAuthLookup: process.argv[2] === "scan" }); const isUpgrade = process.argv[2] === "upgrade"; if (!isCompletion && !isUpgrade) { diff --git a/src/cmd/index.ts b/src/cmd/index.ts index 9e5a323..691dc07 100644 --- a/src/cmd/index.ts +++ b/src/cmd/index.ts @@ -6,6 +6,7 @@ import { registerLoginCommand } from "@/cmd/login"; import { registerLogoutCommand } from "@/cmd/logout"; import { registerRunCommand } from "@/cmd/run"; import { registerRunFileCommand } from "@/cmd/run-file"; +import { registerScanCommand } from "@/cmd/scan"; import { registerSdkCommand } from "@/cmd/sdk"; import { registerUpdateCommand } from "@/cmd/update"; import { registerUpgradeCommand } from "@/cmd/upgrade"; @@ -17,6 +18,7 @@ export function registerCommands(program: Command) { registerLogoutCommand(program); registerWhoamiCommand(program); registerConfigureCommand(program); + registerScanCommand(program); registerRunCommand(program); registerRunFileCommand(program); registerSdkCommand(program); diff --git a/src/cmd/scan.ts b/src/cmd/scan.ts new file mode 100644 index 0000000..8685622 --- /dev/null +++ b/src/cmd/scan.ts @@ -0,0 +1,104 @@ +import { analytics } from "@/lib/analytics"; +import { findBetterleaks, installBetterleaks, runBetterleaks } from "@/lib/betterleaks"; +import { config } from "@/lib/config"; +import { CLIError } from "@/lib/errors"; +import { logger } from "@/lib/logger"; +import { confirm } from "@/ui/Confirm"; +import { showScanReport } from "@/ui/ScanReport"; +import { withSpinner } from "@/ui/Spinner"; +import ansiEscapes from "ansi-escapes"; +import type { Command } from "commander"; + +const BETTERLEAKS_URL = "https://github.com/betterleaks/betterleaks"; + +// Clickable attribution shown on the scanning spinner. Terminals without hyperlink +// support just render the plain text. +const BETTERLEAKS_ATTRIBUTION = `powered by ${ansiEscapes.link("Betterleaks", BETTERLEAKS_URL)}`; + +// General remediation steps, shown whenever secrets are found (regardless of Enkryptify use). +function showRemediation(count: number): void { + logger.info( + `How to fix ${count === 1 ? "this secret" : "these secrets"}:\n` + + " 1. Rotate or revoke each exposed secret now; assume it is already compromised.\n" + + " 2. Remove the secret from your code (and scrub it from your git history).\n" + + " 3. Store it in a secrets manager and inject it at runtime instead of hardcoding it.", + ); +} + +// Subtle product nudge — only for people who have never configured Enkryptify. +async function showEnkryptifyPlug(foundSecrets: boolean): Promise { + if (await config.hasAnyProject()) return; + + if (foundSecrets) { + logger.info( + 'Enkryptify can handle step 3 for you: your secrets stay out of your code and are injected at runtime. Get started with "ek login".', + ); + } else { + logger.info( + 'Want to keep it that way? Enkryptify injects your secrets at runtime so they never touch your code. Get started with "ek login".', + ); + } +} + +export function registerScanCommand(program: Command) { + program + .command("scan") + .description("Scan the current folder (recursively) for hardcoded secrets.") + .action(async () => { + const tracker = analytics.trackCommand("command_scan", {}); + + try { + let bin = await findBetterleaks(); + let installedBetterleaks = false; + + if (!bin) { + logger.info( + "ek scan uses betterleaks (github.com/betterleaks/betterleaks) to scan for secrets, but it isn't installed yet.", + ); + + const ok = await confirm("Install betterleaks now?"); + if (!ok) { + logger.warn("Skipped secret scan.", { + fix: 'Install betterleaks manually from https://github.com/betterleaks/betterleaks/releases, then run "ek scan" again.', + }); + tracker.success({ installed_betterleaks: false, scanned: false }); + return; + } + + bin = await withSpinner("Installing betterleaks...", installBetterleaks); + installedBetterleaks = true; + } + + const findings = await withSpinner( + "Scanning for secrets...", + () => runBetterleaks(bin, process.cwd()), + BETTERLEAKS_ATTRIBUTION, + ); + + if (findings.length > 0) { + await showScanReport(findings); + showRemediation(findings.length); + await showEnkryptifyPlug(true); + process.exitCode = 1; + } else { + logger.success("No secrets found."); + await showEnkryptifyPlug(false); + process.exitCode = 0; + } + + tracker.success({ + installed_betterleaks: installedBetterleaks, + scanned: true, + findings_count: findings.length, + }); + } catch (error) { + tracker.error(error); + if (error instanceof CLIError) { + logger.error(error.message, { why: error.why, fix: error.fix, docs: error.docs }); + } else { + logger.error(error instanceof Error ? error.message : String(error)); + } + process.exit(1); + } + }); +} diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 929c344..848dabf 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -88,7 +88,9 @@ function getSelfSpawnCommand(): string[] { } export const analytics = { - async init(): Promise { + // `skipAuthLookup` avoids reading the keychain for commands that don't need auth + // (e.g. "ek scan"), so they never trigger a keychain password prompt. + async init(options?: { skipAuthLookup?: boolean }): Promise { if (isTestEnvironment() || isOptedOut()) { enabled = false; return; @@ -111,13 +113,15 @@ export const analytics = { distinctId = anonymousId; - try { - const authData: StoredAuthData | null = await secureStore.getAuth(); - if (authData) { - distinctId = authData.userId; + if (!options?.skipAuthLookup) { + try { + const authData: StoredAuthData | null = await secureStore.getAuth(); + if (authData) { + distinctId = authData.userId; + } + } catch { + // Best-effort, continue with anonymous ID } - } catch { - // Best-effort, continue with anonymous ID } superProperties = { diff --git a/src/lib/betterleaks.ts b/src/lib/betterleaks.ts new file mode 100644 index 0000000..dd744e3 --- /dev/null +++ b/src/lib/betterleaks.ts @@ -0,0 +1,184 @@ +import { CLIError } from "@/lib/errors"; +import { getGitRepoInfo } from "@/lib/git"; +import axios from "axios"; +import { execFile, execFileSync } from "child_process"; +import * as fs from "fs"; +import * as fsp from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +const BETTERLEAKS_VERSION = "1.3.0"; +const BETTERLEAKS_DOWNLOAD_BASE = "https://github.com/betterleaks/betterleaks/releases/download"; + +const INSTALL_DIR = path.join(os.homedir(), ".enkryptify", "bin"); +const BINARY_NAME = process.platform === "win32" ? "betterleaks.exe" : "betterleaks"; +const LOCAL_BINARY_PATH = path.join(INSTALL_DIR, BINARY_NAME); + +// betterleaks JSON findings are gitleaks-compatible. We only read the fields the report uses. +export type Finding = { + RuleID: string; + Description: string; + File: string; + StartLine: number; + EndLine: number; + Match: string; + Secret: string; + Entropy: number; +}; + +// betterleaks release assets are named betterleaks___. +// os ∈ {darwin, linux, windows}, arch ∈ {x64, arm64}. Note: arch is "x64", not "x86_64". +function getAssetName(): string | null { + const osMap: Record = { darwin: "darwin", linux: "linux", win32: "windows" }; + const archMap: Record = { x64: "x64", arm64: "arm64" }; + + const osName = osMap[process.platform]; + const arch = archMap[process.arch]; + if (!osName || !arch) return null; + + const ext = process.platform === "win32" ? "zip" : "tar.gz"; + return `betterleaks_${BETTERLEAKS_VERSION}_${osName}_${arch}.${ext}`; +} + +// Resolve a usable betterleaks binary: prefer one on PATH, fall back to our local install. +export async function findBetterleaks(): Promise { + try { + await execFileAsync("betterleaks", ["version"], { timeout: 5000 }); + return "betterleaks"; + } catch { + // Not on PATH; check the local install. + } + + try { + await fsp.access(LOCAL_BINARY_PATH, fs.constants.X_OK); + return LOCAL_BINARY_PATH; + } catch { + return null; + } +} + +// Download and extract the betterleaks binary into ~/.enkryptify/bin. +export async function installBetterleaks(): Promise { + const assetName = getAssetName(); + if (!assetName) { + throw CLIError.from("SCAN_UNSUPPORTED_PLATFORM"); + } + + const downloadUrl = `${BETTERLEAKS_DOWNLOAD_BASE}/v${BETTERLEAKS_VERSION}/${assetName}`; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ek-betterleaks-")); + + try { + const archivePath = path.join(tmpDir, assetName); + + const response = await axios.get(downloadUrl, { responseType: "arraybuffer", timeout: 60000 }); + fs.writeFileSync(archivePath, Buffer.from(response.data as ArrayBuffer)); + + if (assetName.endsWith(".zip")) { + execFileSync("tar", ["-xf", archivePath, "-C", tmpDir], { stdio: "pipe" }); + } else { + execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], { stdio: "pipe" }); + } + + const extractedBinary = path.join(tmpDir, BINARY_NAME); + if (!fs.existsSync(extractedBinary)) { + throw CLIError.from("SCAN_INSTALL_FAILED"); + } + + fs.mkdirSync(INSTALL_DIR, { recursive: true }); + fs.copyFileSync(extractedBinary, LOCAL_BINARY_PATH); + fs.chmodSync(LOCAL_BINARY_PATH, 0o755); + + return LOCAL_BINARY_PATH; + } catch (error) { + if (error instanceof CLIError) throw error; + throw CLIError.from("SCAN_INSTALL_FAILED"); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors. + } + } +} + +function isEnvFile(file: string): boolean { + const base = path.basename(file); + return base === ".env" || base.startsWith(".env."); +} + +async function gitLsFiles(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync("git", ["-C", cwd, "ls-files", "-z", ...args], { + timeout: 15000, + maxBuffer: 64 * 1024 * 1024, + }); + return stdout.split("\0").filter(Boolean); +} + +// Resolve the paths to scan. In a git repo we exclude gitignored files (but always +// keep .env files), so scans match what's actually committed. Returns null when not +// in a git repo, meaning "scan the whole directory". +async function resolveScanTargets(dir: string): Promise { + const repo = await getGitRepoInfo(dir); + if (!repo) return null; + + const [tracked, ignored] = await Promise.all([ + gitLsFiles(dir, ["--cached", "--others", "--exclude-standard"]), + gitLsFiles(dir, ["--others", "--ignored", "--exclude-standard"]), + ]); + + return Array.from(new Set([...tracked, ...ignored.filter(isEnvFile)])); +} + +// Scan a directory and return the parsed findings. We pass --exit-code 0 so betterleaks +// never makes the spawn fail; the caller decides the process exit code from the findings. +export async function runBetterleaks(binPath: string, targetDir: string): Promise { + const targets = await resolveScanTargets(targetDir); + + // Git repo with nothing to scan (everything ignored / empty) — no findings. + if (targets !== null && targets.length === 0) return []; + + const paths = targets ?? [targetDir]; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ek-scan-")); + const reportPath = path.join(tmpDir, "report.json"); + + try { + const proc = Bun.spawn( + [ + binPath, + "dir", + ...paths, + "--report-format", + "json", + "--report-path", + reportPath, + "--exit-code", + "0", + "--no-banner", + ], + { cwd: targetDir, stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ); + await proc.exited; + + if (!fs.existsSync(reportPath)) { + throw CLIError.from("SCAN_RUN_FAILED"); + } + + const raw = await fsp.readFile(reportPath, "utf-8"); + if (!raw.trim()) return []; + + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? (parsed as Finding[]) : []; + } catch (error) { + if (error instanceof CLIError) throw error; + throw CLIError.from("SCAN_RUN_FAILED"); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors. + } + } +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 6cb9a56..b2a5879 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -238,6 +238,11 @@ async function getConfigure(projectPath: string, options: ConfigureOptions = {}) return setup ? { path: setupKey, ...setup } : null; } +async function hasAnyProject(): Promise { + const cfg = await loadConfig(); + return Object.keys(cfg.setups ?? {}).length > 0; +} + async function findProjectConfig(startPath: string): Promise { const config = await loadConfig(); let currentPath = path.resolve(startPath); @@ -275,4 +280,5 @@ export const config = { createConfigure: createConfigureWithOptions, getConfigure, findProjectConfig, + hasAnyProject, }; diff --git a/src/lib/errors.ts b/src/lib/errors.ts index bfb04dc..b203ecb 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -240,4 +240,21 @@ export const CLI_ERRORS = { why: "The GitHub API is unreachable.", fix: "Check your internet connection and try again.", }, + + // Scan + SCAN_UNSUPPORTED_PLATFORM: { + message: "Secret scanning is not available for your platform.", + why: "No betterleaks binary is published for this OS/architecture.", + fix: "Install betterleaks manually from https://github.com/betterleaks/betterleaks/releases and make sure it is on your PATH.", + }, + SCAN_INSTALL_FAILED: { + message: "Could not install betterleaks.", + why: "The download or extraction of the betterleaks binary failed.", + fix: "Check your internet connection and try again, or install it manually from https://github.com/betterleaks/betterleaks/releases", + }, + SCAN_RUN_FAILED: { + message: "The secret scan failed to run.", + why: "betterleaks exited unexpectedly or produced no report.", + fix: "Try again. If the problem persists, run betterleaks directly to see the underlying error.", + }, } as const; diff --git a/src/ui/ScanReport.tsx b/src/ui/ScanReport.tsx new file mode 100644 index 0000000..b011421 --- /dev/null +++ b/src/ui/ScanReport.tsx @@ -0,0 +1,77 @@ +import type { Finding } from "@/lib/betterleaks"; +import { Box, Text, render, useStdout } from "ink"; +import * as path from "path"; + +const MAX_ROWS_TO_DISPLAY = 100; + +// betterleaks reports absolute paths; show them relative to the scanned directory. +function relativeFile(file: string): string { + const rel = path.relative(process.cwd(), file); + return rel && !rel.startsWith("..") ? rel : file; +} + +// Mask a secret so the report never prints it in full: keep a few edge characters. +function redact(secret: string): string { + const value = secret ?? ""; + if (value.length <= 6) return "*".repeat(Math.max(value.length, 3)); + return `${value.slice(0, 3)}${"*".repeat(6)}${value.slice(-2)}`; +} + +function ScanFindings({ findings }: { findings: Finding[] }) { + const { stdout } = useStdout(); + const columns = stdout?.columns ?? 80; + + const display = findings.slice(0, MAX_ROWS_TO_DISPLAY); + const hasMore = findings.length > MAX_ROWS_TO_DISPLAY; + + return ( + + + + ⚠ {findings.length} secret{findings.length !== 1 ? "s" : ""} found + + + {display.map((finding, index) => { + const location = `${relativeFile(finding.File)}:${finding.StartLine}`; + const maxLocation = Math.max(20, columns - 4); + + return ( + + + {finding.RuleID} + + + {" "} + {location.length > maxLocation + ? "…" + location.slice(location.length - maxLocation + 1) + : location} + + + {" Secret: "} + {redact(finding.Secret || finding.Match)} + + + ); + })} + {hasMore && ( + + ... and {findings.length - MAX_ROWS_TO_DISPLAY} more findings + + )} + + ); +} + +// Awaitable so the box is fully painted before the caller prints anything below it. +export async function showScanReport(findings: Finding[]): Promise { + if (findings.length === 0) return; + + const report = render(); + await new Promise((resolve) => process.nextTick(resolve)); + report.unmount(); + await report.waitUntilExit(); +} diff --git a/src/ui/Spinner.tsx b/src/ui/Spinner.tsx new file mode 100644 index 0000000..6ec59cc --- /dev/null +++ b/src/ui/Spinner.tsx @@ -0,0 +1,31 @@ +import { PREFIX } from "@/lib/logger"; +import ansiEscapes from "ansi-escapes"; +import { Box, Text, render } from "ink"; +import Spinner from "ink-spinner"; + +function SpinnerComponent({ message, hint }: { message: string; hint?: string }) { + return ( + + + + + + {PREFIX} {message} + + {hint ? · {hint} : null} + + ); +} + +// Render a spinner to stderr while `fn` runs, then erase it. Mirrors RunFlow's pattern. +// `hint` is shown dimmed after the message (e.g. an attribution). +export async function withSpinner(message: string, fn: () => Promise, hint?: string): Promise { + const spinner = render(, { stdout: process.stderr }); + + try { + return await fn(); + } finally { + spinner.unmount(); + process.stderr.write(ansiEscapes.eraseLines(1)); + } +}