Preserve redacted FTPS failure diagnostics (#213)

## Summary
- Preserve lftp stdout/stderr when the process exits non-zero.
- Surface bounded, whitespace-normalized diagnostics through the deploy
error.
- Redact FTPS host, username, password, path, URL userinfo, and encoded
secret forms.

## Verification
- `vitest run tests/unit/cpanel-deploy.spec.js` (25/25)
- `node --check scripts/release/cpanel-deploy-lib.mjs`
- `git diff --check`

This is the prerequisite diagnostic repair for failed Frontend Release
run 29854900889. Production was not switched during that failure.
This commit is contained in:
Jeppe B
2026-07-22 19:23:04 +02:00
committed by GitHub
parent 7782d93fe9
commit 74dd8e3691
2 changed files with 82 additions and 2 deletions
+24 -2
View File
@@ -287,7 +287,10 @@ export async function defaultProcessRunner(command, args, options = {}) {
}); });
child.on("close", (code) => { child.on("close", (code) => {
if (code !== 0) { 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; return;
} }
resolve({ resolve({
@@ -420,6 +423,21 @@ export function createLftpTransport(config, dependencies = {}) {
const sleep = const sleep =
dependencies.sleep || (async (milliseconds) => await new Promise((resolve) => setTimeout(resolve, milliseconds))); 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) { async function run(commands) {
try { try {
await runner("lftp", ["-f", "/dev/stdin"], { await runner("lftp", ["-f", "/dev/stdin"], {
@@ -427,7 +445,11 @@ export function createLftpTransport(config, dependencies = {}) {
label: "FTPS operation", label: "FTPS operation",
}); });
} catch (error) { } 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 });
} }
} }
+58
View File
@@ -150,6 +150,64 @@ describe("cPanel deployment configuration", () => {
}); });
describe("secure FTPS archive upload", () => { 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", () => { it("forces verified TLS and keeps credentials out of command arguments", () => {
const deploymentConfig = config(); const deploymentConfig = config();
const script = buildLftpScript(deploymentConfig, ["bye"]); const script = buildLftpScript(deploymentConfig, ["bye"]);