Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions packages/ssh/src/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
import * as Sink from "effect/Sink";
Expand Down Expand Up @@ -105,7 +106,7 @@ describe("ssh command", () => {
name: "opt-in tail",
keepErrorOutputTail: true,
exitCode: 1,
stderr: "stderr failure ".repeat(400),
stderr: "stderr failure ".repeat(100),
},
{ name: "successful output", keepErrorOutputTail: true, exitCode: 0, stderr: "" },
])("preserves process output: $name", ({ keepErrorOutputTail, exitCode, stderr }) =>
Expand Down Expand Up @@ -159,12 +160,35 @@ describe("ssh command", () => {
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("redacts credentials from stdout in non-zero command failures", () => {
it.effect("redacts credentials from command output in non-zero command failures", () => {
const token = "fake-pairing-token";
const launchLogTail = (credential: string, qrCode: ReadonlyArray<string>) =>
[
`Token: ${credential}`,
`Pairing URL: http://127.0.0.1:3773/pair#token=${credential}`,
`App link: t3code://pair?url=http%3A%2F%2F127.0.0.1%3A3773%2Fpair%23token%3D${credential}`,
...qrCode,
"Remote T3 server did not become ready on 127.0.0.1:3773.",
].join("\n");
const padding = "earlier launch output\n".repeat(200);
const stderr = `${padding}${launchLogTail(token, [
" █▀▀▀▀▀█ ▄▀▄▄█ █▀▀▀▀▀█ ",
" ▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀▀▀▀▀▀▀ ",
])}\n`;
const redactedStderr = `${padding}${launchLogTail("[redacted]", ["[redacted QR code]"])}\n`;
const warnings: Array<string> = [];
const spawner = ChildProcessSpawner.make(() =>
Effect.succeed(makeFailedProcess({ stdout: '{"credential":"pairing-secret"}\n' })),
Effect.succeed(makeFailedProcess({ stdout: '{"credential":"pairing-secret"}\n', stderr })),
);
const layerSpawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
const layerProcess = Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, layerSpawner);
const layerProcess = Layer.mergeAll(
NodeServices.layer,
NodeCrypto.layer,
layerSpawner,
Logger.layer([Logger.make(({ message }) => warnings.push(JSON.stringify(message)))], {
mergeWithExisting: false,
}),
);

return Effect.gen(function* () {
const result = yield* Effect.result(
Expand All @@ -182,9 +206,14 @@ describe("ssh command", () => {
assert.isTrue(Result.isFailure(result));
if (Result.isFailure(result)) {
assert.instanceOf(result.failure, SshCommandError);
assert.equal(result.failure.message, '{"credential":"[redacted]"}');
assert.equal(result.failure.stdout, '{"credential":"[redacted]"}\n');
assert.equal(result.failure.stderr, `[truncated]\n${redactedStderr.slice(-4_000)}`);
assert.equal(result.failure.message, result.failure.stderr.trim());
}
const failureLog = warnings.find((entry) => entry.includes("ssh.command.failed"));
assert.isDefined(failureLog);
assert.notInclude(failureLog, token);
assert.include(failureLog, "[redacted QR code]");
}).pipe(Effect.provide(layerProcess));
});

Expand Down
27 changes: 19 additions & 8 deletions packages/ssh/src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,20 @@ const collectProcessOutput = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.E
),
);

// Covers the headless server startup output a failed launch tails from server.log.
function redactSshCredentials(output: string): string {
return output
.replace(
/("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu,
"$1[redacted]$2",
)
.replace(/((?:[?#&]|%23|%26|%3F)token(?:=|%3D))[^&#\s"'<>]+/giu, "$1[redacted]")
.replace(/^([ \t]*Token:[ \t]*)\S+/gmu, "$1[redacted]")
.replace(/^ *[█▀▄][ █▀▄]*(?:\r?\n *[█▀▄][ █▀▄]*)*$/gmu, "[redacted QR code]");
}

function redactSshErrorOutput(output: string, keepTail = false): string {
const redacted = output.replace(
/("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu,
"$1[redacted]$2",
);
const redacted = redactSshCredentials(output);
if (redacted.length <= MAX_SSH_ERROR_OUTPUT_LENGTH) return redacted;
return keepTail
? `[truncated]\n${redacted.slice(-MAX_SSH_ERROR_OUTPUT_LENGTH)}`
Expand Down Expand Up @@ -308,28 +317,30 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func

if (exitCode !== 0) {
const diagnosticStdout = redactSshErrorOutput(stdout, input.keepErrorOutputTail);
// ssh and the remote scripts print the failure reason last.
const diagnosticStderr = redactSshErrorOutput(stderr, true);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
yield* Effect.logWarning("ssh.command.failed", {
...sshTargetLogFields(target),
command: ["ssh", ...args],
exitCode,
stdout: diagnosticStdout,
stderr,
stderr: diagnosticStderr,
});
return yield* new SshCommandError({
command: ["ssh", ...args],
exitCode,
stdout: diagnosticStdout,
stderr,
stderr: diagnosticStderr,
message:
hostKeyVerificationMessage({
target,
hostSpec,
stderr,
stderr: diagnosticStderr,
platform: yield* HostProcess.Platform,
}) ??
normalizeSshErrorMessage({
stdout: diagnosticStdout,
stderr,
stderr: diagnosticStderr,
fallbackMessage: `SSH command failed for ${hostSpec} (exit ${exitCode}).`,
}),
});
Expand Down
2 changes: 1 addition & 1 deletion packages/ssh/src/tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,12 +809,12 @@ if [ -z "$REMOTE_PORT" ]; then
printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE"
printf 'managed\\n' >"$MANAGED_FILE"
if ! wait_ready "@@T3_READY_TIMEOUT_MS@@"; then
printf 'Remote T3 server did not become ready on 127.0.0.1:%s.\\n' "$REMOTE_PORT" >&2
if [ -s "$LOG_FILE" ]; then
tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true
else
printf 'It wrote nothing to %s, so it exited before producing any output.\\n' "$LOG_FILE" >&2
fi
printf 'Remote T3 server did not become ready on 127.0.0.1:%s.\\n' "$REMOTE_PORT" >&2
kill "$REMOTE_PID" 2>/dev/null || true
wait_for_pid_exit "$REMOTE_PID"
rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"
Expand Down
Loading