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:
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
Reference in New Issue
Block a user