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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@enkryptify/cli",
"version": "0.3.5",
"version": "0.4.0",
"bin": {
"ek": "./dist/cli.js"
},
Expand Down
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/cmd/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -17,6 +18,7 @@ export function registerCommands(program: Command) {
registerLogoutCommand(program);
registerWhoamiCommand(program);
registerConfigureCommand(program);
registerScanCommand(program);
registerRunCommand(program);
registerRunFileCommand(program);
registerSdkCommand(program);
Expand Down
104 changes: 104 additions & 0 deletions src/cmd/scan.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
});
}
18 changes: 11 additions & 7 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ function getSelfSpawnCommand(): string[] {
}

export const analytics = {
async init(): Promise<void> {
// `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<void> {
if (isTestEnvironment() || isOptedOut()) {
enabled = false;
return;
Expand All @@ -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 = {
Expand Down
184 changes: 184 additions & 0 deletions src/lib/betterleaks.ts
Original file line number Diff line number Diff line change
@@ -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_<version>_<os>_<arch>.<ext>
// os ∈ {darwin, linux, windows}, arch ∈ {x64, arm64}. Note: arch is "x64", not "x86_64".
function getAssetName(): string | null {
const osMap: Record<string, string> = { darwin: "darwin", linux: "linux", win32: "windows" };
const archMap: Record<string, string> = { 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<string | null> {
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<string> {
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);

Comment thread
SiebeBaree marked this conversation as resolved.
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<string[]> {
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<string[] | null> {
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<Finding[]> {
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.
}
}
}
6 changes: 6 additions & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ async function getConfigure(projectPath: string, options: ConfigureOptions = {})
return setup ? { path: setupKey, ...setup } : null;
}

async function hasAnyProject(): Promise<boolean> {
const cfg = await loadConfig();
return Object.keys(cfg.setups ?? {}).length > 0;
}

async function findProjectConfig(startPath: string): Promise<ProjectConfig> {
const config = await loadConfig();
let currentPath = path.resolve(startPath);
Expand Down Expand Up @@ -275,4 +280,5 @@ export const config = {
createConfigure: createConfigureWithOptions,
getConfigure,
findProjectConfig,
hasAnyProject,
};
17 changes: 17 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading