#!/usr/bin/env node "use strict"; const fs = require("fs"); const os = require("os"); const path = require("path"); const args = new Set(process.argv.slice(2)); const dryRun = args.has("--dry-run"); const noBackup = args.has("--no-backup"); const includeEnvFiles = args.has("--include-env-files"); const repoRoot = path.resolve(__dirname, ".."); const homeDir = os.homedir(); function expandHome(value) { if (!value) return value; if (value === "~") return homeDir; if (value.startsWith("~/") || value.startsWith("~\\")) { return path.join(homeDir, value.slice(2)); } return value; } const workingDir = path.resolve( expandHome(process.env.ADCLAW_WORKING_DIR || path.join(homeDir, ".adclaw")), ); const secretDir = path.resolve( expandHome(process.env.ADCLAW_SECRET_DIR || `${workingDir}.secret`), ); const stamp = new Date() .toISOString() .replace(/[-:]/g, "") .replace(/\..+$/, "") .replace("T", "-"); const backupRoot = path.join(secretDir, "backups", stamp); const secretNamePattern = /(^|[_\-.])(api[_\-.]?key|token|secret|credential|authorization|bearer)($|[_\-.])/i; const touched = []; const skipped = []; function exists(filePath) { try { return fs.statSync(filePath).isFile(); } catch { return false; } } function backupPathFor(filePath) { const safeName = path .resolve(filePath) .replace(/^[A-Za-z]:/, "") .replace(/[\\/]+/g, "__") .replace(/^__/, ""); return path.join(backupRoot, safeName); } function backupFile(filePath) { if (noBackup || dryRun || !exists(filePath)) return; const target = backupPathFor(filePath); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(filePath, target); } function removeFile(filePath, reason) { if (!exists(filePath)) { skipped.push({ filePath, reason: "not found" }); return; } touched.push({ action: noBackup ? "deleted" : "backed up and removed", filePath, reason }); if (dryRun) return; backupFile(filePath); fs.unlinkSync(filePath); } function sanitizeValue(value, parentKey = "") { if (Array.isArray(value)) { let changed = false; const next = value.map((item) => { const result = sanitizeValue(item, parentKey); changed = changed || result.changed; return result.value; }); return { value: next, changed }; } if (value && typeof value === "object") { let changed = false; const next = {}; for (const [key, child] of Object.entries(value)) { const keyLooksSecret = secretNamePattern.test(key); if (keyLooksSecret) { changed = true; if (typeof child === "string") { next[key] = ""; } else if (Array.isArray(child)) { next[key] = []; } else if (child && typeof child === "object") { next[key] = {}; } else { next[key] = null; } continue; } const result = sanitizeValue(child, key); changed = changed || result.changed; next[key] = result.value; } return { value: next, changed }; } if (typeof value === "string" && secretNamePattern.test(parentKey) && value) { return { value: "", changed: true }; } return { value, changed: false }; } function sanitizeJsonFile(filePath, reason) { if (!exists(filePath)) { skipped.push({ filePath, reason: "not found" }); return; } let parsed; try { parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch (error) { skipped.push({ filePath, reason: `invalid JSON: ${error.message}` }); return; } const result = sanitizeValue(parsed); if (!result.changed) { skipped.push({ filePath, reason: "no secret-like fields found" }); return; } touched.push({ action: "sanitized", filePath, reason }); if (dryRun) return; backupFile(filePath); fs.writeFileSync(filePath, `${JSON.stringify(result.value, null, 2)}\n`, "utf8"); } function sanitizeEnvFile(filePath) { if (!exists(filePath)) { skipped.push({ filePath, reason: "not found" }); return; } const original = fs.readFileSync(filePath, "utf8"); const lines = original.split(/\r?\n/); let changed = false; const next = lines.map((line) => { const match = line.match(/^(\s*export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/); if (!match) return line; const key = match[2]; if (!secretNamePattern.test(key)) return line; changed = true; return `${match[1] || ""}${key}=`; }); if (!changed) { skipped.push({ filePath, reason: "no API key/token/secret env vars found" }); return; } touched.push({ action: "sanitized", filePath, reason: "env file API key/token/secret lines" }); if (dryRun) return; backupFile(filePath); fs.writeFileSync(filePath, next.join(os.EOL), "utf8"); } function listCompanyDirs() { const companiesDir = path.join(workingDir, "companies"); try { return fs .readdirSync(companiesDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => path.join(companiesDir, entry.name)); } catch { return []; } } function clearRuntimeSecrets() { const filesToRemove = [ path.join(secretDir, "providers.json"), path.join(secretDir, "envs.json"), path.join(workingDir, "providers.json"), path.join(workingDir, "envs.json"), path.join(workingDir, ".secret", "providers.json"), path.join(workingDir, ".secret", "envs.json"), path.join(homeDir, ".linkedin-mcp", "tokens_default.json"), path.join(homeDir, ".linkedin-mcp", "users.json"), ]; for (const companyDir of listCompanyDirs()) { filesToRemove.push( path.join(companyDir, "providers.json"), path.join(companyDir, "envs.json"), path.join(companyDir, "linkedin-mcp", "tokens_default.json"), path.join(companyDir, "linkedin-mcp", "users.json"), ); sanitizeJsonFile( path.join(companyDir, "config.json"), "company MCP/API config secret-like values", ); } sanitizeJsonFile(path.join(workingDir, "config.json"), "MCP/API config secret-like values"); for (const filePath of filesToRemove) { removeFile(filePath, "runtime/provider/env/OAuth secret store"); } } function clearEnvFiles() { for (const name of [ ".env", ".env.local", ".env.development", ".env.testing", ".env.production", ]) { sanitizeEnvFile(path.join(repoRoot, name)); } } if (noBackup && !dryRun) { console.warn( "WARNING: --no-backup is set. Cleared credentials cannot be recovered.", ); } if (!dryRun) { console.warn( "Stop MaskanX before clearing: a running app can rewrite its config from " + "memory and restore the secrets this script removes.", ); } clearRuntimeSecrets(); if (includeEnvFiles) { clearEnvFiles(); } console.log(""); console.log(`MaskanX secret cleanup ${dryRun ? "dry run" : "complete"}.`); console.log(`Working dir: ${workingDir}`); console.log(`Secret dir: ${secretDir}`); if (!noBackup) { console.log(`Backups: ${dryRun ? "(dry run only)" : backupRoot}`); } console.log(""); if (touched.length) { console.log("Changed:"); for (const item of touched) { console.log(`- ${item.action}: ${item.filePath} (${item.reason})`); } } else { console.log("Changed: none"); } console.log(""); console.log("Skipped:"); for (const item of skipped) { console.log(`- ${item.filePath} (${item.reason})`); } if (!includeEnvFiles) { console.log(""); console.log("Tip: run `npm run secrets:clear:all` to also blank API key/token/secret lines in .env files."); } // --------------------------------------------------------------------------- // Verification pass. // // Clearing is not trustworthy on its own: if the MaskanX app is running while // this script executes, it can rewrite config.json / providers.json from its // in-memory state and silently restore the very values we just removed. Re-read // the files afterwards and fail loudly when anything secret-looking survives, // so "secrets cleared" can never be reported when it is not true. // --------------------------------------------------------------------------- function collectRemainingSecrets(value, keyPath, found) { if (Array.isArray(value)) { value.forEach((item, i) => collectRemainingSecrets(item, `${keyPath}[${i}]`, found)); return found; } if (value && typeof value === "object") { for (const [key, child] of Object.entries(value)) { const next = keyPath ? `${keyPath}.${key}` : key; if (secretNamePattern.test(key) && typeof child === "string" && child.trim()) { found.push(next); continue; } collectRemainingSecrets(child, next, found); } } return found; } function verifyJsonFile(filePath) { if (!exists(filePath)) return []; try { const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); return collectRemainingSecrets(parsed, "", []).map((k) => `${filePath} -> ${k}`); } catch { return []; } } if (!dryRun) { const leftovers = [ ...verifyJsonFile(path.join(workingDir, "config.json")), ...verifyJsonFile(path.join(secretDir, "providers.json")), ...verifyJsonFile(path.join(secretDir, "envs.json")), ]; for (const companyDir of listCompanyDirs()) { leftovers.push(...verifyJsonFile(path.join(companyDir, "config.json"))); } console.log(""); if (leftovers.length) { console.error("VERIFICATION FAILED - secrets are still present:"); for (const item of leftovers) console.error(`- ${item}`); console.error(""); console.error( "This usually means MaskanX was running and rewrote its config from memory.", ); console.error("Stop the app (and any `npm run local`/`npm start`), then re-run."); process.exitCode = 1; } else { console.log("Verification passed: no secret-like values remain."); } }