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/api/auth.ts b/src/api/auth.ts index db8f787..af5ebd5 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -2,7 +2,7 @@ import { env } from "@/env"; import { config as configManager } from "@/lib/config"; import { CLIError } from "@/lib/errors"; import { logger } from "@/lib/logger"; -import { keyring } from "@/lib/keyring"; +import { secureStore } from "@/lib/secureStore"; import http from "@/api/httpClient"; import { createHash, randomBytes } from "crypto"; import open from "open"; @@ -28,18 +28,11 @@ type AuthResponse = { expiresIn: number; }; -type StoredAuthData = { - accessToken: string; - userId: string; - email: string; -}; - function base64Url(buf: Buffer) { return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+/g, ""); } export class Auth { - private readonly KEYRING_KEY = "enkryptify"; private readonly CLIENT_ID = "enkryptify-cli"; private readonly REDIRECT_URL = "http://localhost:51823/callback"; private readonly CALLBACK_PORT = 51823; @@ -58,7 +51,7 @@ export class Auth { if (envToken) { if (options?.force) { - await keyring.delete(this.KEYRING_KEY); + await secureStore.clearAll(); } else { const isAuth = await this.getUserInfo(envToken).catch(() => false); if (isAuth) { @@ -69,7 +62,7 @@ export class Auth { await configManager.markAuthenticated(); return; } else { - await keyring.delete(this.KEYRING_KEY); + await secureStore.clearAll(); } } } @@ -374,14 +367,11 @@ h1{font-size:18px;font-weight:600;color:#e4e8ec;letter-spacing:-.01em;margin:0} } private async markAuthenticated(accessToken: string, user: UserInfo): Promise { - await keyring.set( - this.KEYRING_KEY, - JSON.stringify({ - accessToken, - userId: user.id, - email: user.email, - }), - ); + await secureStore.setAuth({ + accessToken, + userId: user.id, + email: user.email, + }); await configManager.markAuthenticated(); } @@ -410,21 +400,11 @@ h1{font-size:18px;font-weight:600;color:#e4e8ec;letter-spacing:-.01em;margin:0} } async getCredentials(): Promise { - const authDataString = await keyring.get(this.KEYRING_KEY); - if (!authDataString) { + const authData = await secureStore.getAuth(); + if (!authData?.accessToken) { throw CLIError.from("AUTH_NOT_LOGGED_IN"); } - try { - const authData = JSON.parse(authDataString) as StoredAuthData; - if (!authData || !authData.accessToken) { - throw CLIError.from("AUTH_NOT_LOGGED_IN"); - } - return { accessToken: authData.accessToken }; - } catch (error: unknown) { - if (error instanceof CLIError) throw error; - logger.debug(error instanceof Error ? error.message : String(error)); - throw CLIError.from("AUTH_NOT_LOGGED_IN"); - } + return { accessToken: authData.accessToken }; } } diff --git a/src/api/client.ts b/src/api/client.ts index 9738b58..201bc56 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,4 @@ -import { type ProjectConfig, config } from "@/lib/config"; +import { type ConfigureScope, type ProjectConfig, config } from "@/lib/config"; import { CLIError } from "@/lib/errors"; import { logger } from "@/lib/logger"; import { getSecureInput, getTextInput } from "@/lib/input"; @@ -98,8 +98,8 @@ class EnkryptifyClient { await this.auth.login(options); } - async configure(options: string): Promise { - const setup = await config.getConfigure(options); + async configure(options: string, configureOptions?: { scope?: ConfigureScope }): Promise { + const setup = await config.getConfigure(options, configureOptions); if (setup) { const overwrite = await confirm("Setup already exists. Overwrite?"); if (!overwrite) { 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/configure.ts b/src/cmd/configure.ts index 584c458..2f5e533 100644 --- a/src/cmd/configure.ts +++ b/src/cmd/configure.ts @@ -1,21 +1,49 @@ -import { config } from "@/lib/config"; +import { type ConfigureScope, config } from "@/lib/config"; import { analytics } from "@/lib/analytics"; import { CLIError } from "@/lib/errors"; +import { getGitRepoInfo } from "@/lib/git"; import { logger } from "@/lib/logger"; import { client } from "@/api/client"; +import { selectName } from "@/ui/SelectItem"; import type { Command } from "commander"; -export async function configure(): Promise> { +type ConfigureCommandOptions = { + git?: boolean; +}; + +const GIT_SCOPE_LABEL = "Git repository (recommended)"; +const PATH_SCOPE_LABEL = "This path only"; + +async function resolveConfigureScope(projectPath: string, options: ConfigureCommandOptions): Promise { + if (options.git) { + return "git"; + } + + const gitRepo = await getGitRepoInfo(projectPath); + if (!gitRepo) { + return "path"; + } + + const selectedScope = await selectName( + [GIT_SCOPE_LABEL, PATH_SCOPE_LABEL], + "Connect this setup to this path or to the Git repository?", + ); + + return selectedScope === PATH_SCOPE_LABEL ? "path" : "git"; +} + +export async function configure(options: ConfigureCommandOptions = {}): Promise> { const authenticated = await config.isAuthenticated(); if (!authenticated) { throw CLIError.from("AUTH_NOT_LOGGED_IN"); } const projectPath = process.cwd(); + const scope = await resolveConfigureScope(projectPath, options); - const projectConfig = await client.configure(projectPath); + const projectConfig = await client.configure(projectPath, { scope }); - await config.createConfigure(projectPath, projectConfig); + await config.createConfigure(projectPath, projectConfig, { scope }); return projectConfig; } @@ -25,11 +53,12 @@ export function registerConfigureCommand(program: Command) { .command("configure") .alias("setup") .description("The configure command is used to set up a project with Enkryptify.") - .action(async () => { + .option("--git", "Connect this setup to the current Git repository instead of only this path") + .action(async (options: ConfigureCommandOptions) => { const tracker = analytics.trackCommand("command_configure"); try { - const projectConfig = await configure(); + const projectConfig = await configure(options); tracker.success({ workspace_slug: projectConfig.workspace_slug, }); 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/login.ts b/src/cmd/login.ts index da810ef..5575276 100644 --- a/src/cmd/login.ts +++ b/src/cmd/login.ts @@ -1,6 +1,6 @@ import { analytics } from "@/lib/analytics"; import { logger } from "@/lib/logger"; -import { keyring } from "@/lib/keyring"; +import { secureStore } from "@/lib/secureStore"; import { Auth } from "@/api/auth"; import { config as configManager } from "@/lib/config"; import { LoginFlow } from "@/ui/LoginFlow"; @@ -21,25 +21,18 @@ export function registerLoginCommand(program: Command) { // when the user is already logged in. if (!options?.force) { try { - const authDataString = await keyring.get("enkryptify"); - if (authDataString) { - const authData = JSON.parse(authDataString) as { - accessToken: string; - userId: string; - email: string; - }; - if (authData.accessToken) { - // Verify the token is still valid - const auth = new Auth(); - const userInfo = await auth.getUserInfo(authData.accessToken).catch(() => null); - if (userInfo) { - logger.info( - 'Already logged in. Use "ek login --force" to re-authenticate with a different account.', - ); - await configManager.markAuthenticated(); - tracker.success(); - return; - } + const authData = await secureStore.getAuth(); + if (authData?.accessToken) { + // Verify the token is still valid + const auth = new Auth(); + const userInfo = await auth.getUserInfo(authData.accessToken).catch(() => null); + if (userInfo) { + logger.info( + 'Already logged in. Use "ek login --force" to re-authenticate with a different account.', + ); + await configManager.markAuthenticated(); + tracker.success(); + return; } } } catch { @@ -59,15 +52,9 @@ export function registerLoginCommand(program: Command) { onComplete: async () => { // Identify user in analytics after successful login try { - const authDataString = await keyring.get("enkryptify"); - if (authDataString) { - const authData = JSON.parse(authDataString) as { - userId: string; - email: string; - }; - if (authData.userId && authData.email) { - analytics.identify(authData.userId, authData.email); - } + const authData = await secureStore.getAuth(); + if (authData) { + analytics.identify(authData.userId, authData.email); } } catch { // Best-effort diff --git a/src/cmd/logout.ts b/src/cmd/logout.ts index e9c49b2..a3a83e7 100644 --- a/src/cmd/logout.ts +++ b/src/cmd/logout.ts @@ -1,8 +1,8 @@ import http from "@/api/httpClient"; import { analytics } from "@/lib/analytics"; import { config as configManager } from "@/lib/config"; -import { keyring } from "@/lib/keyring"; import { logger } from "@/lib/logger"; +import { secureStore } from "@/lib/secureStore"; import type { Command } from "commander"; export function registerLogoutCommand(program: Command) { @@ -13,8 +13,8 @@ export function registerLogoutCommand(program: Command) { const tracker = analytics.trackCommand("command_logout"); try { - const authDataString = await keyring.get("enkryptify"); - if (!authDataString) { + const authData = await secureStore.getAuth(); + if (!authData?.accessToken) { logger.info("You are not logged in."); tracker.success(); return; @@ -30,7 +30,7 @@ export function registerLogoutCommand(program: Command) { logger.debug(revokeError instanceof Error ? revokeError.message : String(revokeError)); } - await keyring.delete("enkryptify"); + await secureStore.clearAll(); await configManager.clearAuthentication(); logger.info("Successfully logged out."); tracker.success(); 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/cmd/sdk.ts b/src/cmd/sdk.ts index 4ef7b41..bca22f0 100644 --- a/src/cmd/sdk.ts +++ b/src/cmd/sdk.ts @@ -28,12 +28,12 @@ export function registerSdkCommand(program: Command): void { process.exit(1); } - // 1. Load project config (walks up from cwd) + // 1. Load project config (walks up from cwd and checks git-scoped setup) let setup; try { - setup = await config.getConfigure(process.cwd()); + setup = await config.findProjectConfig(process.cwd()); } catch { - // getConfigure returns null if not found, doesn't throw + // findProjectConfig throws when no setup exists } if (!setup?.workspace_slug || !setup?.environment_id) { diff --git a/src/cmd/whoami.ts b/src/cmd/whoami.ts index 5c54fa6..54cba76 100644 --- a/src/cmd/whoami.ts +++ b/src/cmd/whoami.ts @@ -1,7 +1,7 @@ import { Auth } from "@/api/auth"; import { analytics } from "@/lib/analytics"; -import { keyring } from "@/lib/keyring"; import { logger } from "@/lib/logger"; +import { secureStore } from "@/lib/secureStore"; import type { Command } from "commander"; export function registerWhoamiCommand(program: Command) { @@ -12,22 +12,8 @@ export function registerWhoamiCommand(program: Command) { const tracker = analytics.trackCommand("command_whoami", {}); try { - const authDataString = await keyring.get("enkryptify"); - if (!authDataString) { - logger.warn("Not logged in.", { - fix: 'Run "ek login" to authenticate.', - }); - tracker.success(); - return; - } - - const authData = JSON.parse(authDataString) as { - accessToken: string; - userId: string; - email: string; - }; - - if (!authData.accessToken) { + const authData = await secureStore.getAuth(); + if (!authData?.accessToken) { logger.warn("Not logged in.", { fix: 'Run "ek login" to authenticate.', }); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index a6f9e24..a741018 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -1,26 +1,10 @@ import { env } from "@/env"; -import { keyring } from "@/lib/keyring"; import { logger } from "@/lib/logger"; import { CLIError } from "@/lib/errors"; import { loadConfig, saveConfig } from "@/lib/config"; +import { type StoredAuthData, secureStore } from "@/lib/secureStore"; import { randomUUID } from "crypto"; -type StoredAuthData = { - accessToken: string; - userId: string; - email: string; -}; - -function isValidStoredAuthData(value: unknown): value is StoredAuthData { - return ( - typeof value === "object" && - value !== null && - typeof (value as Record).accessToken === "string" && - typeof (value as Record).userId === "string" && - typeof (value as Record).email === "string" - ); -} - export type CommandTracker = { success(properties?: Record): void; error(error: unknown): void; @@ -104,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; @@ -127,16 +113,15 @@ export const analytics = { distinctId = anonymousId; - try { - const authDataString = await keyring.get("enkryptify"); - if (authDataString) { - const parsed: unknown = JSON.parse(authDataString); - if (isValidStoredAuthData(parsed)) { - distinctId = parsed.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 = { @@ -148,6 +133,19 @@ export const analytics = { }; enabled = true; + + // One usage event per invocation. $set writes these as person properties + // (last-write-wins), so each person is counted under their current version: + // after an upgrade they move to the new version instead of being + // double-counted on the old one. + analytics.track("version_usage", { + $set: { + cli_version: env.CLI_VERSION, + os: process.platform, + arch: process.arch, + node_version: process.version, + }, + }); } catch { // Analytics initialization should never break the CLI enabled = false; diff --git a/src/lib/betterleaks.ts b/src/lib/betterleaks.ts new file mode 100644 index 0000000..8c936a8 --- /dev/null +++ b/src/lib/betterleaks.ts @@ -0,0 +1,225 @@ +import { CLIError } from "@/lib/errors"; +import { getGitRepoInfo } from "@/lib/git"; +import axios from "axios"; +import { execFile, execFileSync } from "child_process"; +import * as crypto from "crypto"; +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"; + +// SHA-256 of each pinned release asset, copied from betterleaks' official v1.3.0 +// checksums.txt. Pinned in source rather than fetched at runtime so a tampered or +// MITM'd download can't also supply a matching checksum. Update with the version. +const BETTERLEAKS_CHECKSUMS: Record = { + "betterleaks_1.3.0_darwin_arm64.tar.gz": "e2110fc396d96f8795a668f294efd8a84ec96d694d1daf9ce1a4fa788d354f4c", + "betterleaks_1.3.0_darwin_x64.tar.gz": "270e8b46b9b5478199b3facc5ee01bd67659de9c8e57b7b1d6c1336f44ee7822", + "betterleaks_1.3.0_linux_arm64.tar.gz": "53e94d704871e11d47e25304cb0813ce6aa0cf96b58d7b92ed7bc5a1d568efde", + "betterleaks_1.3.0_linux_x64.tar.gz": "51568ae18383996aa24d87807879774e1feb75bd5a5a652d19ce0fd14e4c06ba", + "betterleaks_1.3.0_windows_arm64.zip": "079571796442c527106143024f03a6b21af9e542a59bc1f33b1d9cd042f7a141", + "betterleaks_1.3.0_windows_x64.zip": "6c3c2950befd972e080ebc2f1a7278187efc75f60ea75a737b95e3c8703efdd7", +}; + +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 }); + const archiveBuffer = Buffer.from(response.data as ArrayBuffer); + + const expectedChecksum = BETTERLEAKS_CHECKSUMS[assetName]; + const actualChecksum = crypto.createHash("sha256").update(archiveBuffer).digest("hex"); + if (!expectedChecksum || actualChecksum !== expectedChecksum) { + throw CLIError.from("SCAN_INSTALL_FAILED"); + } + + fs.writeFileSync(archivePath, archiveBuffer); + + 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" }, + ); + + const SCAN_TIMEOUT = 300000; + let timeoutId: ReturnType | undefined; + let exitCode: number; + try { + exitCode = await Promise.race([ + proc.exited, + new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error("timeout")), SCAN_TIMEOUT); + }), + ]); + } catch (error) { + if (error instanceof Error && error.message === "timeout") { + proc.kill(); + throw CLIError.from("SCAN_RUN_FAILED"); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + + // We pass --exit-code 0, so a non-zero exit means betterleaks itself errored. + if (exitCode !== 0 || !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 1dda2bc..b2a5879 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,14 +1,21 @@ import { CLIError } from "@/lib/errors"; +import { getGitRepoInfo } from "@/lib/git"; import { logger } from "@/lib/logger"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; +export type ConfigureScope = "path" | "git"; + export type ProjectConfig = { path: string; [key: string]: string; }; +type ConfigureOptions = { + scope?: ConfigureScope; +}; + type ConfigFile = { setups: { [projectPath: string]: Record; @@ -190,23 +197,50 @@ async function isAuthenticated(): Promise { return config.providers?.["enkryptify"] != null; } -async function createConfigure(projectPath: string, projectConfig: ProjectConfig): Promise { +async function getSetupKey(projectPath: string, scope: ConfigureScope): Promise { + if (scope === "path") { + return path.resolve(projectPath); + } + + const gitRepo = await getGitRepoInfo(projectPath); + if (!gitRepo) { + throw new CLIError( + "No Git repository found.", + "The current directory is not inside a Git repository.", + 'Run "ek configure" without --git to set up this path, or run it from inside a Git repository.', + "/cli/troubleshooting#configuration", + ); + } + + return gitRepo.setupKey; +} + +async function createConfigureWithOptions( + projectPath: string, + projectConfig: ProjectConfig, + options: ConfigureOptions = {}, +): Promise { const config = await loadConfig(); - const normalizedPath = path.resolve(projectPath); + const setupKey = await getSetupKey(projectPath, options.scope ?? "path"); if (!config.setups) config.setups = {}; const { path: _, ...setupData } = projectConfig; - config.setups[normalizedPath] = setupData; + config.setups[setupKey] = setupData; await saveConfig(config); } -async function getConfigure(projectPath: string): Promise { +async function getConfigure(projectPath: string, options: ConfigureOptions = {}): Promise { const config = await loadConfig(); - const normalizedPath = path.resolve(projectPath); - const setup = config.setups?.[normalizedPath]; - return setup ? { path: normalizedPath, ...setup } : null; + const setupKey = await getSetupKey(projectPath, options.scope ?? "path"); + const setup = config.setups?.[setupKey]; + 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 { @@ -223,6 +257,14 @@ async function findProjectConfig(startPath: string): Promise { currentPath = path.dirname(currentPath); } + const gitRepo = await getGitRepoInfo(startPath); + if (gitRepo) { + const setup = config.setups?.[gitRepo.setupKey]; + if (setup) { + return { path: gitRepo.setupKey, ...setup }; + } + } + throw new CLIError( "No project configured for this directory.", "No Enkryptify configuration was found in this directory or any parent directory.", @@ -235,7 +277,8 @@ export const config = { markAuthenticated, clearAuthentication, isAuthenticated, - createConfigure, + 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/lib/git.ts b/src/lib/git.ts new file mode 100644 index 0000000..957b746 --- /dev/null +++ b/src/lib/git.ts @@ -0,0 +1,47 @@ +import { execFile } from "child_process"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +export const GIT_SETUP_PREFIX = "git:"; + +export type GitRepoInfo = { + root: string; + commonDir: string; + setupKey: string; +}; + +export async function getGitRepoInfo(startPath: string): Promise { + const cwd = path.resolve(startPath); + + try { + const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel", "--git-common-dir"], { + timeout: 3000, + }); + + const [rootOutput, commonDirOutput] = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + if (!rootOutput || !commonDirOutput) { + return null; + } + + const root = path.resolve(rootOutput); + const resolvedCommonDir = path.isAbsolute(commonDirOutput) + ? path.resolve(commonDirOutput) + : path.resolve(cwd, commonDirOutput); + const commonDir = await fs.realpath(resolvedCommonDir); + + return { + root, + commonDir, + setupKey: `${GIT_SETUP_PREFIX}${commonDir}`, + }; + } catch { + return null; + } +} diff --git a/src/lib/secretCache.ts b/src/lib/secretCache.ts index a56af5a..86cec48 100644 --- a/src/lib/secretCache.ts +++ b/src/lib/secretCache.ts @@ -1,7 +1,7 @@ import type { Secret } from "@/api/client"; import type { ProjectConfig } from "@/lib/config"; import { CLIError } from "@/lib/errors"; -import { keyring } from "@/lib/keyring"; +import { secureStore } from "@/lib/secureStore"; const CACHE_TTL_MS = 10_000; // 10 seconds const CACHE_KEY_PREFIX = "secret-cache:"; @@ -28,11 +28,6 @@ function buildCacheKey(workspaceSlug: string, projectSlug: string, environmentKe return `${CACHE_KEY_PREFIX}${workspaceSlug}/${projectSlug}/${environmentKey}`; } -function encode(entry: CacheEntry): string { - const json = JSON.stringify(entry); - return btoa(json); -} - function decode(encoded: string): CacheEntry | null { try { const json = atob(encoded); @@ -44,9 +39,7 @@ function decode(encoded: string): CacheEntry | null { async function readCache(key: string): Promise { try { - const raw = await keyring.get(key); - if (!raw) return null; - return decode(raw); + return await secureStore.getSecretCacheEntry(key); } catch { return null; } @@ -55,12 +48,16 @@ async function readCache(key: string): Promise { async function writeCache(key: string, secrets: Secret[]): Promise { try { const entry: CacheEntry = { secrets, timestamp: Date.now() }; - await keyring.set(key, encode(entry)); + await secureStore.setSecretCacheEntry(key, entry); } catch { // Keyring unavailable; silently degrade } } +async function readLegacyCache(key: string): Promise { + return await secureStore.migrateLegacySecretCacheEntry(key, decode); +} + export async function fetchSecretsWithCache( config: ProjectConfig, runOptions: { env?: string; project?: string }, @@ -103,7 +100,7 @@ export async function fetchSecretsWithCache( // --offline: use cache only, never fetch if (cacheOptions.offline) { - const cached = await readCache(cacheKey); + const cached = (await readCache(cacheKey)) ?? (await readLegacyCache(cacheKey)); if (!cached) { throw new CLIError( "No cached secrets available.", @@ -115,7 +112,7 @@ export async function fetchSecretsWithCache( } // Normal mode: check TTL, fetch if stale, fallback to cache on error - const cached = await readCache(cacheKey); + const cached = (await readCache(cacheKey)) ?? (await readLegacyCache(cacheKey)); if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { return { secrets: cached.secrets, fromCache: true, cacheReason: "ttl" }; } diff --git a/src/lib/secureStore.ts b/src/lib/secureStore.ts new file mode 100644 index 0000000..0e0120f --- /dev/null +++ b/src/lib/secureStore.ts @@ -0,0 +1,167 @@ +import type { Secret } from "@/api/client"; +import { keyring } from "@/lib/keyring"; + +export const SECURE_STORE_KEY = "enkryptify"; +export const SECURE_STORE_VERSION = 1; + +class AsyncMutex { + private locked = false; + private waiters: Array<() => void> = []; + + async acquire(): Promise { + if (!this.locked) { + this.locked = true; + return; + } + return new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + release(): void { + const waiter = this.waiters.shift(); + if (waiter) { + waiter(); + } else { + this.locked = false; + } + } +} + +const storeMutex = new AsyncMutex(); + +export type StoredAuthData = { + accessToken: string; + userId: string; + email: string; +}; + +export type StoredSecretCacheEntry = { + secrets: Secret[]; + timestamp: number; +}; + +type SecureStoreData = { + version: 1; + auth?: StoredAuthData; + secretCache?: Record; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStoredAuthData(value: unknown): value is StoredAuthData { + return ( + isRecord(value) && + typeof value.accessToken === "string" && + typeof value.userId === "string" && + typeof value.email === "string" + ); +} + +function parseStore(raw: string | null): { store: SecureStoreData; legacyAuth: boolean } { + if (!raw) { + return { store: { version: SECURE_STORE_VERSION }, legacyAuth: false }; + } + + try { + const parsed: unknown = JSON.parse(raw); + + if (isStoredAuthData(parsed)) { + return { store: { version: SECURE_STORE_VERSION, auth: parsed }, legacyAuth: true }; + } + + if (isRecord(parsed) && parsed.version === SECURE_STORE_VERSION) { + return { store: parsed as SecureStoreData, legacyAuth: false }; + } + } catch { + // Corrupted secure-store data is treated as empty and overwritten + // by the next successful auth/cache write. + } + + return { store: { version: SECURE_STORE_VERSION }, legacyAuth: false }; +} + +async function readStore(options: { upgradeLegacy?: boolean } = {}): Promise { + const raw = await keyring.get(SECURE_STORE_KEY); + const { store, legacyAuth } = parseStore(raw); + + if (options.upgradeLegacy && legacyAuth) { + await writeStore(store); + } + + return store; +} + +async function writeStore(store: SecureStoreData): Promise { + await keyring.set(SECURE_STORE_KEY, JSON.stringify(store)); +} + +async function updateStore(updater: (store: SecureStoreData) => void): Promise { + await storeMutex.acquire(); + try { + const store = await readStore(); + updater(store); + await writeStore(store); + } finally { + storeMutex.release(); + } +} + +export const secureStore = { + async getAuth(): Promise { + const store = await readStore({ upgradeLegacy: true }); + return store.auth ?? null; + }, + + async setAuth(auth: StoredAuthData): Promise { + await updateStore((store) => { + store.auth = auth; + }); + }, + + async clearAll(): Promise { + await keyring.delete(SECURE_STORE_KEY); + }, + + async getSecretCacheEntry(cacheKey: string): Promise { + const store = await readStore({ upgradeLegacy: true }); + return store.secretCache?.[cacheKey] ?? null; + }, + + async setSecretCacheEntry(cacheKey: string, entry: StoredSecretCacheEntry): Promise { + await updateStore((store) => { + store.secretCache = { + ...(store.secretCache ?? {}), + [cacheKey]: entry, + }; + }); + }, + + async migrateLegacySecretCacheEntry( + cacheKey: string, + decode: (raw: string) => StoredSecretCacheEntry | null, + ): Promise { + try { + const raw = await keyring.get(cacheKey); + if (!raw) return null; + + const entry = decode(raw); + if (!entry) return null; + + await updateStore((store) => { + store.secretCache = { + ...(store.secretCache ?? {}), + [cacheKey]: entry, + }; + }); + + await keyring.delete(cacheKey).catch(() => undefined); + + return entry; + } catch { + return null; + } + }, +}; diff --git a/src/lib/sharedHttpClient.ts b/src/lib/sharedHttpClient.ts index a986a54..7ddb55d 100644 --- a/src/lib/sharedHttpClient.ts +++ b/src/lib/sharedHttpClient.ts @@ -1,13 +1,8 @@ import { CLIError } from "@/lib/errors"; -import { keyring } from "@/lib/keyring"; import { logger } from "@/lib/logger"; +import { secureStore } from "@/lib/secureStore"; import axios, { type AxiosError, type AxiosInstance } from "axios"; -type StoredAuthData = { - accessToken: string; - [key: string]: string; -}; - export type HttpClientConfig = { baseURL: string; keyringKey: string; @@ -66,12 +61,7 @@ export function createAuthenticatedHttpClient(config: HttpClientConfig): AxiosIn http.interceptors.request.use( async (requestConfig) => { try { - const authDataString = await keyring.get(config.keyringKey); - if (!authDataString) { - return requestConfig; - } - - const authData = JSON.parse(authDataString) as StoredAuthData; + const authData = await secureStore.getAuth(); const token = authData?.accessToken; if ( 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)); + } +} diff --git a/tests/integration/config.test.ts b/tests/integration/config.test.ts index 08cff66..fff5990 100644 --- a/tests/integration/config.test.ts +++ b/tests/integration/config.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +import { execFileSync } from "child_process"; import type * as ConfigModule from "@/lib/config"; vi.mock("@/lib/logger"); @@ -176,6 +177,88 @@ describe("config (integration)", () => { expect(found.path).toBe(path.resolve(parentPath)); }); + it("finds git-scoped config from another worktree", async () => { + const repoPath = path.join(tmpDir, "repo"); + const worktreePath = path.join(tmpDir, "repo-worktree"); + fs.mkdirSync(repoPath, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoPath }); + fs.writeFileSync(path.join(repoPath, "README.md"), "test\n", "utf-8"); + execFileSync("git", ["add", "README.md"], { cwd: repoPath }); + execFileSync("git", ["-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "init"], { + cwd: repoPath, + }); + execFileSync("git", ["worktree", "add", "-b", "test-worktree", worktreePath], { cwd: repoPath }); + + await config.createConfigure( + repoPath, + { + path: repoPath, + workspace_slug: "ws-git", + project_slug: "proj-git", + environment_id: "env-git", + }, + { scope: "git" }, + ); + + const found = await config.findProjectConfig(worktreePath); + expect(found.workspace_slug).toBe("ws-git"); + expect(found.project_slug).toBe("proj-git"); + expect(found.path).toMatch(/^git:/); + }); + + it("uses the same git-scoped config key from repo subdirectories", async () => { + const repoPath = path.join(tmpDir, "repo-subdir"); + const subdirPath = path.join(repoPath, "packages", "app"); + fs.mkdirSync(subdirPath, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoPath }); + + await config.createConfigure( + repoPath, + { + path: repoPath, + workspace_slug: "ws-git", + project_slug: "proj-git", + environment_id: "env-git", + }, + { scope: "git" }, + ); + + const found = await config.findProjectConfig(subdirPath); + expect(found.workspace_slug).toBe("ws-git"); + expect(found.project_slug).toBe("proj-git"); + expect(found.path).toMatch(/^git:/); + }); + + it("prefers path-scoped config over git-scoped config", async () => { + const repoPath = path.join(tmpDir, "repo-with-override"); + const childPath = path.join(repoPath, "packages", "app"); + fs.mkdirSync(childPath, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoPath }); + + await config.createConfigure( + repoPath, + { + path: repoPath, + workspace_slug: "ws-git", + project_slug: "proj-git", + environment_id: "env-git", + }, + { scope: "git" }, + ); + + await config.createConfigure(childPath, { + path: childPath, + workspace_slug: "ws-path", + project_slug: "proj-path", + environment_id: "env-path", + }); + + const found = await config.findProjectConfig(childPath); + expect(found.workspace_slug).toBe("ws-path"); + expect(found.project_slug).toBe("proj-path"); + expect(found.path).toBe(path.resolve(childPath)); + }); + it("throws CLIError when no config found", async () => { const randomPath = path.join(tmpDir, "no-config-here"); fs.mkdirSync(randomPath, { recursive: true }); diff --git a/tests/integration/configure-command.test.ts b/tests/integration/configure-command.test.ts new file mode 100644 index 0000000..8cf0bc9 --- /dev/null +++ b/tests/integration/configure-command.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FAKE_PROJECT_CONFIG } from "../helpers/fixtures"; + +vi.mock("@/lib/logger"); + +vi.mock("@/lib/config", () => ({ + config: { + isAuthenticated: vi.fn(), + createConfigure: vi.fn(), + }, +})); + +vi.mock("@/api/client", () => ({ + client: { + configure: vi.fn(), + }, +})); + +vi.mock("@/lib/git", () => ({ + getGitRepoInfo: vi.fn(), +})); + +vi.mock("@/ui/SelectItem", () => ({ + selectName: vi.fn(), +})); + +import { client } from "@/api/client"; +import { configure } from "@/cmd/configure"; +import { config } from "@/lib/config"; +import { getGitRepoInfo } from "@/lib/git"; +import { selectName } from "@/ui/SelectItem"; + +describe("configure command", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(process, "cwd").mockReturnValue("/tmp/repo"); + vi.mocked(config.isAuthenticated).mockResolvedValue(true); + vi.mocked(config.createConfigure).mockResolvedValue(undefined); + vi.mocked(client.configure).mockResolvedValue(FAKE_PROJECT_CONFIG); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("uses git scope when --git is provided", async () => { + await configure({ git: true }); + + expect(selectName).not.toHaveBeenCalled(); + expect(client.configure).toHaveBeenCalledWith("/tmp/repo", { scope: "git" }); + expect(config.createConfigure).toHaveBeenCalledWith("/tmp/repo", FAKE_PROJECT_CONFIG, { scope: "git" }); + }); + + it("asks for setup scope inside a git repo and defaults to git option", async () => { + vi.mocked(getGitRepoInfo).mockResolvedValue({ + root: "/tmp/repo", + commonDir: "/tmp/repo/.git", + setupKey: "git:/tmp/repo/.git", + }); + vi.mocked(selectName).mockResolvedValue("Git repository (recommended)"); + + await configure(); + + expect(selectName).toHaveBeenCalledWith( + ["Git repository (recommended)", "This path only"], + "Connect this setup to this path or to the Git repository?", + ); + expect(client.configure).toHaveBeenCalledWith("/tmp/repo", { scope: "git" }); + expect(config.createConfigure).toHaveBeenCalledWith("/tmp/repo", FAKE_PROJECT_CONFIG, { scope: "git" }); + }); + + it("keeps path scope outside a git repo without asking an extra question", async () => { + vi.mocked(getGitRepoInfo).mockResolvedValue(null); + + await configure(); + + expect(selectName).not.toHaveBeenCalled(); + expect(client.configure).toHaveBeenCalledWith("/tmp/repo", { scope: "path" }); + expect(config.createConfigure).toHaveBeenCalledWith("/tmp/repo", FAKE_PROJECT_CONFIG, { scope: "path" }); + }); +}); diff --git a/tests/integration/configure-flow.test.ts b/tests/integration/configure-flow.test.ts index a92be4c..a86310c 100644 --- a/tests/integration/configure-flow.test.ts +++ b/tests/integration/configure-flow.test.ts @@ -166,6 +166,12 @@ describe("client.configure() flow (integration)", () => { expect(confirm).toHaveBeenCalledWith("Setup already exists. Overwrite?"); }); + it("checks existing setup in the selected scope", async () => { + await client.configure("/tmp/test", { scope: "git" }); + + expect(config.getConfigure).toHaveBeenCalledWith("/tmp/test", { scope: "git" }); + }); + it("returns existing config when user declines overwrite", async () => { const existingConfig = { path: "/tmp/test", diff --git a/tests/integration/http-client.test.ts b/tests/integration/http-client.test.ts index 965cfee..eee48ab 100644 --- a/tests/integration/http-client.test.ts +++ b/tests/integration/http-client.test.ts @@ -21,6 +21,7 @@ vi.mock("@/lib/logger"); import { createAuthenticatedHttpClient } from "@/lib/sharedHttpClient"; const BASE_URL = "http://localhost:9876"; +const UNIFIED_AUTH_STORE = { version: 1, auth: FAKE_AUTH_DATA }; const server = setupServer(); @@ -53,7 +54,7 @@ describe("createAuthenticatedHttpClient (integration)", () => { // --- Request interceptor tests --- it("adds Authorization header from keyring", async () => { - mockKeyring.seed({ enkryptify: JSON.stringify(FAKE_AUTH_DATA) }); + mockKeyring.seed({ enkryptify: JSON.stringify(UNIFIED_AUTH_STORE) }); let capturedHeaders: Headers | null = null; server.use( @@ -71,7 +72,7 @@ describe("createAuthenticatedHttpClient (integration)", () => { }); it("prepends 'Bearer ' prefix to token", async () => { - mockKeyring.seed({ enkryptify: JSON.stringify(FAKE_AUTH_DATA) }); + mockKeyring.seed({ enkryptify: JSON.stringify(UNIFIED_AUTH_STORE) }); let capturedHeaders: Headers | null = null; server.use( @@ -123,7 +124,7 @@ describe("createAuthenticatedHttpClient (integration)", () => { }); it("does not overwrite an existing auth header", async () => { - mockKeyring.seed({ enkryptify: JSON.stringify(FAKE_AUTH_DATA) }); + mockKeyring.seed({ enkryptify: JSON.stringify(UNIFIED_AUTH_STORE) }); let capturedHeaders: Headers | null = null; server.use( diff --git a/tests/integration/login-flow.test.ts b/tests/integration/login-flow.test.ts index 0d21ac7..2b95bbc 100644 --- a/tests/integration/login-flow.test.ts +++ b/tests/integration/login-flow.test.ts @@ -166,7 +166,7 @@ describe("Auth.login() flow (integration)", () => { // New token should be stored (old one replaced) const stored = await mockKeyring.get("enkryptify"); const parsed = JSON.parse(stored!); - expect(parsed.accessToken).toBe("new-access-token-xyz"); + expect(parsed.auth.accessToken).toBe("new-access-token-xyz"); }, 5000); // --- PKCE flow --- @@ -260,9 +260,14 @@ describe("Auth.login() flow (integration)", () => { const stored = await mockKeyring.get("enkryptify"); expect(stored).toBeTruthy(); const parsed = JSON.parse(stored!); - expect(parsed.accessToken).toBe("new-access-token-xyz"); - expect(parsed.userId).toBe("user-test-123"); - expect(parsed.email).toBe("test@enkryptify.com"); + expect(parsed).toEqual({ + version: 1, + auth: { + accessToken: "new-access-token-xyz", + userId: "user-test-123", + email: "test@enkryptify.com", + }, + }); }, 5000); it("calls config.markAuthenticated() after storing token", async () => { @@ -335,6 +340,6 @@ describe("Auth.login() flow (integration)", () => { // New token should be stored const stored = await mockKeyring.get("enkryptify"); const parsed = JSON.parse(stored!); - expect(parsed.accessToken).toBe("new-access-token-xyz"); + expect(parsed.auth.accessToken).toBe("new-access-token-xyz"); }, 5000); }); diff --git a/tests/integration/logout-command.test.ts b/tests/integration/logout-command.test.ts new file mode 100644 index 0000000..8b44271 --- /dev/null +++ b/tests/integration/logout-command.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; +import { InMemoryKeyring } from "../helpers/mock-keyring"; +import { FAKE_AUTH_DATA, FAKE_SECRETS } from "../helpers/fixtures"; + +const mockKeyring = new InMemoryKeyring(); + +vi.mock("@/lib/keyring", () => ({ + keyring: { + get: (...args: unknown[]) => mockKeyring.get(...(args as [string])), + set: (...args: unknown[]) => mockKeyring.set(...(args as [string, string])), + delete: (...args: unknown[]) => mockKeyring.delete(...(args as [string])), + has: (...args: unknown[]) => mockKeyring.has(...(args as [string])), + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("@/lib/analytics", () => ({ + analytics: { + trackCommand: vi.fn(), + }, +})); + +vi.mock("@/lib/config", () => ({ + config: { + clearAuthentication: vi.fn(), + }, +})); + +vi.mock("@/api/httpClient", () => ({ + default: { + post: vi.fn(), + }, +})); + +import http from "@/api/httpClient"; +import { registerLogoutCommand } from "@/cmd/logout"; +import { analytics, type CommandTracker } from "@/lib/analytics"; +import { config } from "@/lib/config"; +import { logger } from "@/lib/logger"; + +describe("logout command", () => { + let trackerSuccess: ReturnType) => void>>; + let trackerError: ReturnType void>>; + + beforeEach(() => { + vi.clearAllMocks(); + mockKeyring.reset(); + + trackerSuccess = vi.fn<(properties?: Record) => void>(); + trackerError = vi.fn<(error: unknown) => void>(); + vi.mocked(analytics.trackCommand).mockReturnValue({ + success: trackerSuccess, + error: trackerError, + } satisfies CommandTracker); + vi.mocked(http.post).mockResolvedValue({ status: 200, data: {} }); + vi.mocked(config.clearAuthentication).mockResolvedValue(undefined); + }); + + it("clears auth and cached secrets from the unified secure store", async () => { + await mockKeyring.set( + "enkryptify", + JSON.stringify({ + version: 1, + auth: FAKE_AUTH_DATA, + secretCache: { + "secret-cache:test-workspace/test-project/env-test-123": { + secrets: FAKE_SECRETS, + timestamp: 1700000000000, + }, + }, + }), + ); + + const program = new Command(); + registerLogoutCommand(program); + await program.parseAsync(["node", "ek", "logout"]); + + expect(http.post).toHaveBeenCalledWith("/v1/auth/cli/logout", {}); + expect(await mockKeyring.get("enkryptify")).toBeNull(); + expect(config.clearAuthentication).toHaveBeenCalledOnce(); + expect(trackerSuccess).toHaveBeenCalledOnce(); + expect(trackerError).not.toHaveBeenCalled(); + }); + + it("does not call logout API when no auth exists", async () => { + const program = new Command(); + registerLogoutCommand(program); + await program.parseAsync(["node", "ek", "logout"]); + + expect(http.post).not.toHaveBeenCalled(); + expect(config.clearAuthentication).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith("You are not logged in."); + expect(trackerSuccess).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/integration/secret-cache.test.ts b/tests/integration/secret-cache.test.ts index 835c8c4..99b9765 100644 --- a/tests/integration/secret-cache.test.ts +++ b/tests/integration/secret-cache.test.ts @@ -42,6 +42,10 @@ describe("fetchSecretsWithCache (integration)", () => { return vi.fn().mockRejectedValue(error); } + function legacyEncode(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64"); + } + // --- noCache mode --- it("noCache=true always calls fetcher", async () => { @@ -131,6 +135,18 @@ describe("fetchSecretsWithCache (integration)", () => { const fetcher = makeFetcher(); await fetchSecretsWithCache(FAKE_PROJECT_CONFIG, defaultRunOptions, {}, fetcher); + const stored = await mockKeyring.get("enkryptify"); + const cacheKey = `secret-cache:${FAKE_PROJECT_CONFIG.workspace_slug}/${FAKE_PROJECT_CONFIG.project_slug}/${FAKE_PROJECT_CONFIG.environment_id}`; + expect(JSON.parse(stored!)).toEqual({ + version: 1, + secretCache: { + [cacheKey]: { + secrets: FAKE_SECRETS, + timestamp: NOW, + }, + }, + }); + // Verify cache was written by reading it back within TTL const fetcher2 = makeFetcher(); const result = await fetchSecretsWithCache(FAKE_PROJECT_CONFIG, defaultRunOptions, {}, fetcher2); @@ -254,4 +270,35 @@ describe("fetchSecretsWithCache (integration)", () => { expect(result.fromCache).toBe(false); expect(result.secrets).toEqual(FAKE_SECRETS); }); + + it("migrates legacy per-cache key into unified secure store", async () => { + const cacheKey = `secret-cache:${FAKE_PROJECT_CONFIG.workspace_slug}/${FAKE_PROJECT_CONFIG.project_slug}/${FAKE_PROJECT_CONFIG.environment_id}`; + await mockKeyring.set( + cacheKey, + legacyEncode({ + secrets: FAKE_SECRETS, + timestamp: NOW, + }), + ); + + const fetcher = makeFetcher(); + const result = await fetchSecretsWithCache(FAKE_PROJECT_CONFIG, defaultRunOptions, {}, fetcher); + + expect(fetcher).not.toHaveBeenCalled(); + expect(result.fromCache).toBe(true); + expect(result.cacheReason).toBe("ttl"); + expect(result.secrets).toEqual(FAKE_SECRETS); + expect(await mockKeyring.get(cacheKey)).toBeNull(); + + const stored = await mockKeyring.get("enkryptify"); + expect(JSON.parse(stored!)).toEqual({ + version: 1, + secretCache: { + [cacheKey]: { + secrets: FAKE_SECRETS, + timestamp: NOW, + }, + }, + }); + }); }); diff --git a/tests/integration/secure-store.test.ts b/tests/integration/secure-store.test.ts new file mode 100644 index 0000000..93fc1c4 --- /dev/null +++ b/tests/integration/secure-store.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { InMemoryKeyring } from "../helpers/mock-keyring"; +import { FAKE_AUTH_DATA, FAKE_SECRETS } from "../helpers/fixtures"; + +const mockKeyring = new InMemoryKeyring(); + +vi.mock("@/lib/keyring", () => { + return { + keyring: { + get: (...args: unknown[]) => mockKeyring.get(...(args as [string])), + set: (...args: unknown[]) => mockKeyring.set(...(args as [string, string])), + delete: (...args: unknown[]) => mockKeyring.delete(...(args as [string])), + has: (...args: unknown[]) => mockKeyring.has(...(args as [string])), + }, + }; +}); + +vi.mock("@/lib/logger"); + +import { secureStore } from "@/lib/secureStore"; + +function encodeLegacyCache(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64"); +} + +describe("secureStore", () => { + beforeEach(() => { + mockKeyring.reset(); + }); + + it("stores auth in the unified versioned item", async () => { + await secureStore.setAuth(FAKE_AUTH_DATA); + + const raw = await mockKeyring.get("enkryptify"); + expect(JSON.parse(raw!)).toEqual({ + version: 1, + auth: FAKE_AUTH_DATA, + }); + }); + + it("preserves secret cache when auth is updated", async () => { + await secureStore.setSecretCacheEntry("secret-cache:test-workspace/test-project/env-test-123", { + secrets: FAKE_SECRETS, + timestamp: 1700000000000, + }); + + await secureStore.setAuth(FAKE_AUTH_DATA); + + const raw = await mockKeyring.get("enkryptify"); + expect(JSON.parse(raw!)).toEqual({ + version: 1, + auth: FAKE_AUTH_DATA, + secretCache: { + "secret-cache:test-workspace/test-project/env-test-123": { + secrets: FAKE_SECRETS, + timestamp: 1700000000000, + }, + }, + }); + }); + + it("preserves auth when secret cache is updated", async () => { + await secureStore.setAuth(FAKE_AUTH_DATA); + + await secureStore.setSecretCacheEntry("secret-cache:test-workspace/test-project/env-test-123", { + secrets: FAKE_SECRETS, + timestamp: 1700000000000, + }); + + const auth = await secureStore.getAuth(); + expect(auth).toEqual(FAKE_AUTH_DATA); + }); + + it("upgrades legacy auth-only JSON in place when read", async () => { + await mockKeyring.set("enkryptify", JSON.stringify(FAKE_AUTH_DATA)); + + const auth = await secureStore.getAuth(); + + expect(auth).toEqual(FAKE_AUTH_DATA); + const raw = await mockKeyring.get("enkryptify"); + expect(JSON.parse(raw!)).toEqual({ + version: 1, + auth: FAKE_AUTH_DATA, + }); + }); + + it("treats corrupted unified JSON as empty and overwrites it on the next write", async () => { + await mockKeyring.set("enkryptify", "not-valid-json{{{"); + + await expect(secureStore.getAuth()).resolves.toBeNull(); + await secureStore.setAuth(FAKE_AUTH_DATA); + + const raw = await mockKeyring.get("enkryptify"); + expect(JSON.parse(raw!)).toEqual({ + version: 1, + auth: FAKE_AUTH_DATA, + }); + }); + + it("migrates a legacy per-cache key into the unified item", async () => { + const cacheKey = "secret-cache:test-workspace/test-project/env-test-123"; + const entry = { secrets: FAKE_SECRETS, timestamp: 1700000000000 }; + await mockKeyring.set(cacheKey, encodeLegacyCache(entry)); + + const migrated = await secureStore.migrateLegacySecretCacheEntry(cacheKey, (raw) => { + try { + return JSON.parse(Buffer.from(raw, "base64").toString("utf8")); + } catch { + return null; + } + }); + + expect(migrated).toEqual(entry); + expect(await mockKeyring.get(cacheKey)).toBeNull(); + const raw = await mockKeyring.get("enkryptify"); + expect(JSON.parse(raw!)).toEqual({ + version: 1, + secretCache: { + [cacheKey]: entry, + }, + }); + }); + + it("ignores corrupted legacy per-cache data", async () => { + const cacheKey = "secret-cache:test-workspace/test-project/env-test-123"; + await mockKeyring.set(cacheKey, "not-valid-base64-!!!@@@"); + + const migrated = await secureStore.migrateLegacySecretCacheEntry(cacheKey, () => null); + + expect(migrated).toBeNull(); + expect(await mockKeyring.get("enkryptify")).toBeNull(); + expect(await mockKeyring.get(cacheKey)).toBe("not-valid-base64-!!!@@@"); + }); +});