diff --git a/scripts/release/cpanel-deploy-lib.mjs b/scripts/release/cpanel-deploy-lib.mjs index d7058a16..3c391ec8 100644 --- a/scripts/release/cpanel-deploy-lib.mjs +++ b/scripts/release/cpanel-deploy-lib.mjs @@ -287,7 +287,10 @@ export async function defaultProcessRunner(command, args, options = {}) { }); child.on("close", (code) => { if (code !== 0) { - reject(new DeploymentError(`${options.label || command} failed with exit code ${code}.`)); + const error = new DeploymentError(`${options.label || command} failed with exit code ${code}.`); + error.stdout = Buffer.concat(output).toString("utf8"); + error.stderr = Buffer.concat(errors).toString("utf8"); + reject(error); return; } resolve({ @@ -420,6 +423,21 @@ export function createLftpTransport(config, dependencies = {}) { const sleep = dependencies.sleep || (async (milliseconds) => await new Promise((resolve) => setTimeout(resolve, milliseconds))); + function redactDiagnostic(value) { + let result = String(value || ""); + const escapedHost = config.ftp.host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + result = result.replace(new RegExp(escapedHost, "gi"), "[redacted]"); + const rawSecrets = [config.ftp.user, config.ftp.password, config.ftp.root] + .filter((secret) => secret && secret !== "/") + .flatMap((secret) => [secret, encodeURIComponent(secret)]); + const secrets = [...new Set(rawSecrets)].sort((left, right) => right.length - left.length); + for (const secret of secrets) { + result = result.replaceAll(secret, "[redacted]"); + } + result = result.replace(/\b(?:ftp|ftps):\/\/[^\s@]+@/gi, "ftps://[redacted]@"); + return result.replace(/\s+/g, " ").trim().slice(0, 2_000); + } + async function run(commands) { try { await runner("lftp", ["-f", "/dev/stdin"], { @@ -427,7 +445,11 @@ export function createLftpTransport(config, dependencies = {}) { label: "FTPS operation", }); } catch (error) { - throw new DeploymentError("Secure FTPS operation failed.", { cause: error }); + const diagnostic = redactDiagnostic( + [error?.stderr, error?.stdout, error instanceof Error ? error.message : error].filter(Boolean).join(" ") + ); + const suffix = diagnostic ? `: ${diagnostic}` : "."; + throw new DeploymentError(`Secure FTPS operation failed${suffix}`, { cause: error }); } } diff --git a/tests/unit/cpanel-deploy.spec.js b/tests/unit/cpanel-deploy.spec.js index c8f298f9..7071ecc3 100644 --- a/tests/unit/cpanel-deploy.spec.js +++ b/tests/unit/cpanel-deploy.spec.js @@ -150,6 +150,64 @@ describe("cPanel deployment configuration", () => { }); describe("secure FTPS archive upload", () => { + it("reports actionable lftp diagnostics without exposing deployment credentials", async () => { + const deploymentConfig = config(); + const runnerError = Object.assign(new Error(`connection to ${deploymentConfig.ftp.host} failed`), { + stderr: `ftps://${deploymentConfig.ftp.user}:${deploymentConfig.ftp.password}@${deploymentConfig.ftp.host}: 530 Login incorrect`, + stdout: `cd ${deploymentConfig.ftp.root}: Access failed`, + }); + const transport = createLftpTransport(deploymentConfig, { + runner: vi.fn(async () => { + throw runnerError; + }), + }); + + let error; + try { + await transport.removeRelease("old-release"); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(DeploymentError); + expect(error.message).toContain("530 Login incorrect"); + expect(error.message).toContain("[redacted]"); + expect(error.message).not.toContain(deploymentConfig.ftp.host); + expect(error.message).not.toContain(deploymentConfig.ftp.user); + expect(error.message).not.toContain(deploymentConfig.ftp.password); + }); + + it("redacts overlapping credentials longest-first and host names case-insensitively", async () => { + const deploymentConfig = readDeploymentConfig( + validEnv({ + PRODUCTION_FTP_HOST: "Ftp.Example.Test:21", + PRODUCTION_FTP_USER: "prod", + PRODUCTION_FTP_PASSWORD: "prod-secret", + PRODUCTION_FTP_PATH: "/deploy/prod", + }) + ); + const runnerError = Object.assign(new Error("FTPS failed"), { + stderr: "authentication failed for prod-secret at FTP.EXAMPLE.TEST:21 under /deploy/prod", + }); + const transport = createLftpTransport(deploymentConfig, { + runner: vi.fn(async () => { + throw runnerError; + }), + }); + + let error; + try { + await transport.removeRelease("old-release"); + } catch (caught) { + error = caught; + } + + expect(error.message).toContain("authentication failed"); + expect(error.message).not.toContain("-secret"); + expect(error.message).not.toContain("FTP.EXAMPLE.TEST:21"); + expect(error.message).not.toContain("/deploy/prod"); + }); + it("forces verified TLS and keeps credentials out of command arguments", () => { const deploymentConfig = config(); const script = buildLftpScript(deploymentConfig, ["bye"]);