Compare commits

...
Author SHA1 Message Date
bincxzandCursor 63f1d43a6f fix(ssh): keep packaged argv[1] hosts like prod.js
Suffix filtering now applies only after an Electron binary, so a packaged launch can still use a first-position hostname that looks like a script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 12:10:59 +08:00
bincxzandCursor f88ac34013 fix(ssh): keep .app/.exe hosts and reject -loghost
Suffix filtering now only covers the Electron script argument, so destinations like prod.app are not dropped. -loghost is rejected because this pipeline cannot honor the logical host-key identity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 12:06:18 +08:00
bincxzandCursor 7cf799ee74 fix(ssh): reject unsupported -pwfile, -i, and -hostkey launches
Those options request credentials or a host-key pin this pipeline cannot honor, so connecting with -pw or a vault match would silently change the authentication the caller asked for.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 12:01:53 +08:00
bincxzandCursor 7d4506fdaa fix(ssh): reject missing or empty PuTTY option operands
An empty or trailing -pw was turned into ssh://user:@host, which the deep-link path treats as no password and can then authenticate with a saved vault credential.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:56:43 +08:00
bincxzandCursor 226624c076 fix(ssh): reject unsupported PuTTY protocols and unknown flags
-raw/-rlogin/-serial must not fall through to SSH with the supplied password, and valueless switches such as -batch must not consume the following host. Unknown dash flags are now rejected instead of guessed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:50:05 +08:00
bincxzandCursor c87d78f0cb fix(ssh): consume operands of ignored PuTTY flags
Unknown value-taking options such as -m and -D were skipped while their next token was treated as a host or port, which could connect the supplied password to the wrong endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:44:41 +08:00
bincxzandCursor 338ba179ae feat(ssh): accept PuTTY-style CLI args from bastion hosts
Reuse the existing ssh:// and telnet:// deep-link path so PAM products that launch putty.exe -ssh user@host -P port -pw password can point at Netcatty instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:38:26 +08:00
10 changed files with 683 additions and 9 deletions

No files matched your search

+1 -1
View File
@@ -489,7 +489,7 @@ export const enVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
'settings.sshDeepLink.title': 'SSH and Telnet links',
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps, plus PuTTY-style launches such as -ssh user@host -P 22 -pw password.',
'settings.explorerContextMenu.title': 'Windows Explorer',
'settings.explorerContextMenu.enable': 'Show “Open in Netcatty” in folder context menus',
'settings.explorerContextMenu.enableDesc': 'Adds a right-click item for folders and folder backgrounds so you can open a local terminal there. Applies immediately; no reinstall needed.',
+1 -1
View File
@@ -489,7 +489,7 @@ export const esVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': 'Transferir el archivo al host activo del otro panel',
'settings.sshDeepLink.title': 'Enlaces SSH y Telnet',
'settings.sshDeepLink.enable': 'Abrir enlaces ssh:// y telnet:// con Netcatty',
'settings.sshDeepLink.enableDesc': 'Permite que Netcatty maneje los enlaces ssh:// y telnet:// de los navegadores y otras aplicaciones.',
'settings.sshDeepLink.enableDesc': 'Permite que Netcatty maneje los enlaces ssh:// y telnet:// de los navegadores y otras aplicaciones, además de lanzamientos al estilo PuTTY como -ssh user@host -P 22 -pw password.',
'settings.explorerContextMenu.title': 'Explorador de Windows',
'settings.explorerContextMenu.enable': 'Mostrar "Abrir en Netcatty" en los menús contextuales de carpetas',
'settings.explorerContextMenu.enableDesc': 'Agrega un elemento de clic derecho para las carpetas y los fondos de carpeta, para que puedas abrir una terminal local allí. Se aplica de inmediato; no es necesario reinstalar.',
+1 -1
View File
@@ -525,7 +525,7 @@ export const ruVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений, а также запуски в стиле PuTTY, например -ssh user@host -P 22 -pw password.',
'settings.explorerContextMenu.title': 'Проводник Windows',
'settings.explorerContextMenu.enable': 'Показывать «Open in Netcatty» в контекстном меню папок',
'settings.explorerContextMenu.enableDesc': 'Добавляет пункт в меню папок и фона папки, чтобы открыть локальный терминал в этом каталоге. Применяется сразу, переустановка не нужна.',
+1 -1
View File
@@ -131,7 +131,7 @@ export const zhCNTerminalMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接,以及堡垒机常用的 PuTTY 启动参数(例如 -ssh user@host -P 22 -pw password)。',
'settings.explorerContextMenu.title': 'Windows 资源管理器',
'settings.explorerContextMenu.enable': '在文件夹右键菜单中显示 “Open in Netcatty”',
'settings.explorerContextMenu.enableDesc': '为文件夹和文件夹空白处添加右键项,可直接在该目录打开本地终端。开关立即生效,无需重装。',
+1 -1
View File
@@ -131,7 +131,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結,以及堡壘機常用的 PuTTY 啟動參數(例如 -ssh user@host -P 22 -pw password)。',
'settings.explorerContextMenu.title': 'Windows 檔案總管',
'settings.explorerContextMenu.enable': '在資料夾右鍵選單中顯示 “Open in Netcatty”',
'settings.explorerContextMenu.enableDesc': '為資料夾與資料夾空白處加入右鍵項目,可直接在該目錄開啟本機終端機。開關立即生效,無需重裝。',
+23
View File
@@ -1,5 +1,9 @@
const fs = require("node:fs");
const path = require("node:path");
const {
parsePuttyCommandLine,
redactPuttyCommandLinePasswords,
} = require("./puttyCommandLine.cjs");
const SSH_DEEP_LINK_CHANNEL = "netcatty:deepLink:ssh";
const TELNET_DEEP_LINK_CHANNEL = "netcatty:deepLink:telnet";
@@ -44,6 +48,23 @@ function collectJmsDeepLinkUrls(argv) {
return collectDeepLinkUrls(argv, JMS_PROTOCOL);
}
function collectPuttyStyleDeepLinkUrls(argv) {
if (
collectSshDeepLinkUrls(argv).length > 0
|| collectTelnetDeepLinkUrls(argv).length > 0
|| collectJmsDeepLinkUrls(argv).length > 0
) {
return { ssh: [], telnet: [] };
}
const parsed = parsePuttyCommandLine(argv);
if (!parsed?.url) return { ssh: [], telnet: [] };
if (parsed.protocol === TELNET_PROTOCOL) {
return { ssh: [], telnet: [parsed.url] };
}
return { ssh: [parsed.url], telnet: [] };
}
function registerProtocolClient({
app,
protocol,
@@ -358,8 +379,10 @@ module.exports = {
applyJmsProtocolClientPreference,
applySshProtocolClientPreference,
collectJmsDeepLinkUrls,
collectPuttyStyleDeepLinkUrls,
collectSshDeepLinkUrls,
collectTelnetDeepLinkUrls,
redactPuttyCommandLinePasswords,
isJmsDeepLinkUrl,
isSshDeepLinkUrl,
isTelnetDeepLinkUrl,
+29
View File
@@ -7,6 +7,7 @@ const {
applyJmsProtocolClientPreference,
applySshProtocolClientPreference,
collectJmsDeepLinkUrls,
collectPuttyStyleDeepLinkUrls,
collectSshDeepLinkUrls,
collectTelnetDeepLinkUrls,
isJmsDeepLinkUrl,
@@ -43,6 +44,34 @@ test("collectSshDeepLinkUrls extracts ssh URLs from process arguments", () => {
);
});
test("collectPuttyStyleDeepLinkUrls converts PuTTY argv when no ssh:// token is present", () => {
assert.deepEqual(
collectPuttyStyleDeepLinkUrls([
String.raw`C:\Program Files\Netcatty\Netcatty.exe`,
"-ssh",
"alice@10.0.0.8",
"-P",
"2222",
"-pw",
"s3cret",
]),
{ ssh: ["ssh://alice:s3cret@10.0.0.8:2222"], telnet: [] },
);
});
test("collectPuttyStyleDeepLinkUrls leaves ssh:// tokens to the existing collector", () => {
assert.deepEqual(
collectPuttyStyleDeepLinkUrls([
"Netcatty.exe",
"-url",
"ssh://alice@example.com",
"-ssh",
"ignored@host",
]),
{ ssh: [], telnet: [] },
);
});
test("applySshProtocolClientPreference registers or removes ssh and telnet handlers", () => {
const calls = [];
const app = {
+26 -4
View File
@@ -72,8 +72,10 @@ const {
applyJmsProtocolClientPreference,
applySshProtocolClientPreference,
collectJmsDeepLinkUrls,
collectPuttyStyleDeepLinkUrls,
collectSshDeepLinkUrls,
collectTelnetDeepLinkUrls,
redactPuttyCommandLinePasswords,
isJmsDeepLinkUrl,
isSshDeepLinkUrl,
isTelnetDeepLinkUrl,
@@ -580,8 +582,18 @@ async function createAndShowMainWindow() {
}
let sshDeepLinkEnabled = readSshDeepLinkEnabledPreference({ app });
const pendingSshDeepLinkUrls = sshDeepLinkEnabled ? collectSshDeepLinkUrls(process.argv) : [];
const pendingTelnetDeepLinkUrls = sshDeepLinkEnabled ? collectTelnetDeepLinkUrls(process.argv) : [];
const puttyStyleDeepLinks = sshDeepLinkEnabled
? collectPuttyStyleDeepLinkUrls(process.argv)
: { ssh: [], telnet: [] };
const pendingSshDeepLinkUrls = sshDeepLinkEnabled
? [...collectSshDeepLinkUrls(process.argv), ...puttyStyleDeepLinks.ssh]
: [];
const pendingTelnetDeepLinkUrls = sshDeepLinkEnabled
? [...collectTelnetDeepLinkUrls(process.argv), ...puttyStyleDeepLinks.telnet]
: [];
if (sshDeepLinkEnabled) {
redactPuttyCommandLinePasswords(process.argv);
}
const pendingOpenTerminalPaths = resolveOpenTerminalPathsFromArgs(process.argv);
let flushingSshDeepLinks = false;
let flushingTelnetDeepLinks = false;
@@ -982,8 +994,18 @@ if (!gotLock) {
app.on("second-instance", (_event, argv, workingDirectory) => {
const jmsDeepLinkUrls = collectJmsDeepLinkUrls(argv);
const telnetDeepLinkUrls = collectTelnetDeepLinkUrls(argv);
const sshDeepLinkUrls = collectSshDeepLinkUrls(argv);
const puttyStyleDeepLinks = collectPuttyStyleDeepLinkUrls(argv);
const telnetDeepLinkUrls = [
...collectTelnetDeepLinkUrls(argv),
...puttyStyleDeepLinks.telnet,
];
const sshDeepLinkUrls = [
...collectSshDeepLinkUrls(argv),
...puttyStyleDeepLinks.ssh,
];
if (jmsDeepLinkUrls.length > 0 || sshDeepLinkUrls.length > 0 || telnetDeepLinkUrls.length > 0) {
redactPuttyCommandLinePasswords(argv);
}
if (jmsDeepLinkUrls.length > 0) {
if (jmsDeepLinkEnabled) {
jmsDeepLinkUrls.forEach(queueJmsDeepLink);
+273
View File
@@ -0,0 +1,273 @@
const SSH_PROTOCOL = "ssh";
const TELNET_PROTOCOL = "telnet";
const PROTOCOL_FLAGS = new Map([
["-ssh", SSH_PROTOCOL],
["-telnet", TELNET_PROTOCOL],
]);
const UNSUPPORTED_PROTOCOL_FLAGS = new Set([
"-raw", "-rlogin", "-serial", "-ssh-connection", "-supdup",
]);
const VALUE_FLAGS = new Set([
"-P", "-p", "-l", "-pw", "-pwfile",
"-load", "-i", "-m", "-cmd",
"-L", "-R", "-D", "-nc",
"-hostkey", "-loghost",
"-sercfg", "-sessionlog", "-sshlog", "-sshrawlog", "-proxycmd",
"-newtab", "-title", "-url",
]);
const REJECT_VALUE_FLAGS = new Set(["-pwfile", "-i", "-hostkey", "-loghost"]);
const PORT_FLAGS = new Set(["-P", "-p"]);
const SKIP_FLAGS = new Set([
"-1", "-2", "-4", "-6", "-ipv4", "-ipv6",
"-A", "-a",
"-C",
"-N",
"-T", "-t",
"-X", "-x",
"-v",
"-agent", "-pagent", "-pageant",
"-noagent", "-nopagent", "-nopageant",
"-share", "-noshare",
"-batch",
"-nethack",
"-pgpfp",
"-cleanup",
"-help",
"-no-antispoof",
"-sanitise-stderr", "-sanitise-stdout",
"-no-sanitise-stderr", "-no-sanitise-stdout",
"-restrict-acl", "-restrict_acl", "-restrictacl",
]);
function parsePort(raw) {
if (typeof raw !== "string" || !raw.trim()) return null;
const port = Number(raw.trim());
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
return port;
}
function looksLikeIpv4(value) {
return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value);
}
function looksLikeIpv6(value) {
return (value.match(/:/g) || []).length >= 2;
}
function isElectronExecutableName(arg) {
if (typeof arg !== "string") return false;
const base = arg.replace(/^.*[/\\]/, "").toLowerCase();
return base === "electron" || base === "electron.exe";
}
function isElectronNoiseArg(arg, index, argv) {
if (typeof arg !== "string") return true;
if (index === 0) return true;
if (arg === "." || arg === "--") return true;
if (arg.startsWith("--")) return true;
// Dev argv is [electron, main.cjs|app.asar, ...]; packaged argv is [Netcatty.exe, ...user args].
if (
index === 1
&& isElectronExecutableName(argv?.[0])
&& /\.(?:js|cjs|mjs|asar)$/i.test(arg)
&& !arg.includes("@")
) {
return true;
}
if ((arg.includes("/") || arg.includes("\\")) && !arg.includes("@")) return true;
return false;
}
function parseHostSpec(raw) {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith("-")) return null;
let username;
let hostPart = trimmed;
const at = trimmed.lastIndexOf("@");
if (at >= 0) {
username = trimmed.slice(0, at).trim();
hostPart = trimmed.slice(at + 1).trim();
if (!username) username = undefined;
}
if (!hostPart) return null;
let hostname;
let port;
if (hostPart.startsWith("[")) {
const end = hostPart.indexOf("]");
if (end < 0) return null;
hostname = hostPart.slice(1, end).trim();
const rest = hostPart.slice(end + 1);
if (rest) {
if (!rest.startsWith(":")) return null;
port = parsePort(rest.slice(1));
if (port === null) return null;
}
} else if (!looksLikeIpv6(hostPart) && hostPart.includes(":")) {
const colon = hostPart.lastIndexOf(":");
hostname = hostPart.slice(0, colon).trim();
port = parsePort(hostPart.slice(colon + 1));
if (port === null) return null;
} else {
hostname = hostPart.replace(/^\[(.*)\]$/, "$1").trim();
}
if (!hostname) return null;
return {
...(username ? { username } : {}),
hostname,
...(port ? { port } : {}),
};
}
function hostCandidateScore(spec) {
if (!spec) return 0;
let score = 1;
if (spec.username) score += 4;
if (spec.port) score += 2;
if (looksLikeIpv4(spec.hostname) || looksLikeIpv6(spec.hostname)) score += 3;
else if (spec.hostname.includes(".")) score += 2;
return score;
}
function encodeUserInfo(username, password) {
if (username) {
const user = encodeURIComponent(username);
return password !== undefined ? `${user}:${encodeURIComponent(password)}` : user;
}
if (password !== undefined) return `:${encodeURIComponent(password)}`;
return "";
}
function formatHostnameForUrl(hostname) {
return hostname.includes(":") ? `[${hostname}]` : hostname;
}
function toDeepLinkUrl({ protocol, username, password, hostname, port }) {
const auth = encodeUserInfo(username, password);
const host = formatHostnameForUrl(hostname);
const portPart = port ? `:${port}` : "";
return `${protocol}://${auth ? `${auth}@` : ""}${host}${portPart}`;
}
function hasPuttyLaunchSignal(argv) {
if (!Array.isArray(argv)) return false;
return argv.some((arg) => {
if (typeof arg !== "string") return false;
return PROTOCOL_FLAGS.has(arg) || arg === "-pw" || arg === "-pwfile" || arg === "-l" || PORT_FLAGS.has(arg);
});
}
function parsePuttyCommandLine(argv) {
if (!Array.isArray(argv) || !hasPuttyLaunchSignal(argv)) return null;
let protocol = SSH_PROTOCOL;
let username;
let password;
let port;
const positionals = [];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (typeof arg !== "string" || !arg) continue;
if (isElectronNoiseArg(arg, index, argv)) continue;
const protocolFromFlag = PROTOCOL_FLAGS.get(arg);
if (protocolFromFlag) {
protocol = protocolFromFlag;
continue;
}
if (UNSUPPORTED_PROTOCOL_FLAGS.has(arg)) return null;
if (SKIP_FLAGS.has(arg)) continue;
if (VALUE_FLAGS.has(arg)) {
const value = argv[index + 1];
if (typeof value !== "string") return null;
index += 1;
if (REJECT_VALUE_FLAGS.has(arg)) return null;
if (PORT_FLAGS.has(arg)) {
const parsedPort = parsePort(value);
if (parsedPort === null) return null;
port = parsedPort;
continue;
}
if (arg === "-l") {
const nextUser = value.trim();
if (!nextUser) return null;
username = nextUser;
continue;
}
if (arg === "-pw") {
if (value === "") return null;
password = value;
continue;
}
if (!value.trim()) return null;
continue;
}
if (arg.startsWith("-")) return null;
const positionalPort = parsePort(arg);
if (positionalPort !== null && /^\d+$/.test(arg.trim())) {
if (port === undefined) port = positionalPort;
continue;
}
const spec = parseHostSpec(arg);
if (spec) {
positionals.push(spec);
}
}
if (positionals.length === 0) return null;
const hostSpec = positionals.reduce((best, candidate) => (
hostCandidateScore(candidate) > hostCandidateScore(best) ? candidate : best
));
const resolvedUsername = (username || hostSpec.username || "").trim() || undefined;
const resolvedPort = port ?? hostSpec.port;
const hostname = hostSpec.hostname;
if (!hostname) return null;
const url = toDeepLinkUrl({
protocol,
username: resolvedUsername,
password,
hostname,
port: resolvedPort,
});
return {
protocol,
url,
hostname,
...(resolvedUsername ? { username: resolvedUsername } : {}),
...(password !== undefined ? { password } : {}),
...(resolvedPort ? { port: resolvedPort } : {}),
};
}
function redactPuttyCommandLinePasswords(argv) {
if (!Array.isArray(argv)) return argv;
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] !== "-pw") continue;
const next = argv[index + 1];
if (typeof next !== "string") continue;
argv[index + 1] = "*".repeat(Math.min(next.length, 8)) || "********";
}
return argv;
}
module.exports = {
parsePuttyCommandLine,
redactPuttyCommandLinePasswords,
};
+327
View File
@@ -0,0 +1,327 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
parsePuttyCommandLine,
redactPuttyCommandLinePasswords,
} = require("./puttyCommandLine.cjs");
test("parsePuttyCommandLine accepts JumpServer PuTTY template", () => {
assert.deepEqual(
parsePuttyCommandLine([
String.raw`C:\Program Files\Netcatty\Netcatty.exe`,
"-ssh",
"alice@10.0.0.8",
"-P",
"2222",
"-pw",
"s3cret",
]),
{
protocol: "ssh",
url: "ssh://alice:s3cret@10.0.0.8:2222",
hostname: "10.0.0.8",
username: "alice",
password: "s3cret",
port: 2222,
},
);
});
test("parsePuttyCommandLine accepts -l username and mixed flag order", () => {
const parsed = parsePuttyCommandLine([
"Netcatty.exe",
"bastion.example.com",
"-P",
"22",
"-l",
"ops",
"-pw",
"hunter2",
"-ssh",
]);
assert.equal(parsed?.url, "ssh://ops:hunter2@bastion.example.com:22");
assert.equal(parsed?.username, "ops");
});
test("parsePuttyCommandLine prefers user@host over -newtab session names", () => {
const parsed = parsePuttyCommandLine([
"Netcatty.exe",
"-newtab",
"Production",
"-ssh",
"root@192.168.10.2",
"-P",
"22",
"-pw",
"one-time",
]);
assert.equal(parsed?.hostname, "192.168.10.2");
assert.equal(parsed?.username, "root");
assert.equal(parsed?.url, "ssh://root:one-time@192.168.10.2:22");
});
test("parsePuttyCommandLine percent-encodes passwords for ssh:// round-trip", () => {
const parsed = parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"alice@example.com",
"-pw",
"p@ss:w/rd",
]);
assert.equal(parsed?.url, "ssh://alice:p%40ss%3Aw%2Frd@example.com");
assert.equal(parsed?.password, "p@ss:w/rd");
});
test("parsePuttyCommandLine accepts IPv6 hosts and optional positional port", () => {
const parsed = parsePuttyCommandLine([
"electron",
".",
"-ssh",
"bob@[2001:db8::10]",
"2200",
]);
assert.equal(parsed?.hostname, "2001:db8::10");
assert.equal(parsed?.port, 2200);
assert.equal(parsed?.url, "ssh://bob@[2001:db8::10]:2200");
});
test("parsePuttyCommandLine accepts user@host:port without -P", () => {
const parsed = parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"alice@bastion.example.com:2222",
"-pw",
"token",
]);
assert.equal(parsed?.url, "ssh://alice:token@bastion.example.com:2222");
});
test("parsePuttyCommandLine accepts telnet launches", () => {
const parsed = parsePuttyCommandLine([
"Netcatty.exe",
"-telnet",
"router.lab",
"-P",
"23",
]);
assert.equal(parsed?.protocol, "telnet");
assert.equal(parsed?.url, "telnet://router.lab:23");
});
test("parsePuttyCommandLine ignores Chromium flags and missing hosts", () => {
assert.equal(parsePuttyCommandLine(["Netcatty.exe", "--enable-logging", "-ssh"]), null);
assert.equal(parsePuttyCommandLine(["Netcatty.exe", "just-a-file.txt"]), null);
assert.equal(parsePuttyCommandLine(["Netcatty.exe", "-P", "22"]), null);
});
test("parsePuttyCommandLine rejects invalid ports", () => {
assert.equal(parsePuttyCommandLine(["Netcatty.exe", "-ssh", "host.example", "-P", "99999"]), null);
});
test("parsePuttyCommandLine does not treat -m operands as the destination host", () => {
const parsed = parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"-l",
"alice",
"server.example",
"-m",
"commands.txt",
"-pw",
"secret",
]);
assert.equal(parsed?.hostname, "server.example");
assert.equal(parsed?.username, "alice");
assert.equal(parsed?.url, "ssh://alice:secret@server.example");
});
test("parsePuttyCommandLine does not treat -D operands as the destination port", () => {
const parsed = parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"alice@server.example",
"-D",
"1080",
"-pw",
"secret",
]);
assert.equal(parsed?.hostname, "server.example");
assert.equal(parsed?.port, undefined);
assert.equal(parsed?.url, "ssh://alice:secret@server.example");
});
test("parsePuttyCommandLine consumes operands of known value-taking flags such as -cmd", () => {
const parsed = parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"alice@server.example",
"-cmd",
"whoami",
"-pw",
"secret",
]);
assert.equal(parsed?.hostname, "server.example");
assert.equal(parsed?.url, "ssh://alice:secret@server.example");
});
test("parsePuttyCommandLine rejects unsupported PuTTY protocol selectors", () => {
assert.equal(parsePuttyCommandLine([
"Netcatty.exe",
"server.example",
"-raw",
"-P",
"8000",
"-pw",
"secret",
]), null);
assert.equal(parsePuttyCommandLine([
"Netcatty.exe",
"-serial",
"COM1",
"-P",
"22",
]), null);
});
test("parsePuttyCommandLine keeps the host after valueless options such as -batch", () => {
const parsed = parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"-batch",
"alice@server.example",
"-pw",
"secret",
]);
assert.equal(parsed?.url, "ssh://alice:secret@server.example");
});
test("parsePuttyCommandLine rejects unknown dash flags", () => {
assert.equal(parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"-not-a-putty-flag",
"alice@server.example",
]), null);
});
test("parsePuttyCommandLine rejects missing or empty option operands", () => {
assert.equal(parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"alice@server.example",
"-pw",
]), null);
assert.equal(parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"alice@server.example",
"-pw",
"",
]), null);
assert.equal(parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"alice@server.example",
"-P",
]), null);
assert.equal(parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"server.example",
"-l",
"",
"-pw",
"secret",
]), null);
});
test("parsePuttyCommandLine rejects unsupported authentication and host-key overrides", () => {
assert.equal(parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"alice@server.example",
"-pwfile",
"secret.txt",
]), null);
assert.equal(parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"alice@server.example",
"-i",
"id_rsa.ppk",
"-pw",
"secret",
]), null);
assert.equal(parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"alice@server.example",
"-hostkey",
"aa:bb:cc:dd",
"-pw",
"secret",
]), null);
assert.equal(parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"alice@server.example",
"-loghost",
"logical.example",
"-pw",
"secret",
]), null);
});
test("parsePuttyCommandLine accepts destinations with executable-like suffixes", () => {
const parsed = parsePuttyCommandLine([
"Netcatty.exe",
"-ssh",
"alice@prod.app",
"-pw",
"secret",
]);
assert.equal(parsed?.hostname, "prod.app");
assert.equal(parsed?.url, "ssh://alice:secret@prod.app");
const positional = parsePuttyCommandLine([
"Netcatty.exe",
"gateway.exe",
"-ssh",
"-l",
"alice",
"-pw",
"secret",
]);
assert.equal(positional?.hostname, "gateway.exe");
assert.equal(positional?.url, "ssh://alice:secret@gateway.exe");
const scriptLikeHost = parsePuttyCommandLine([
"Netcatty.exe",
"prod.js",
"-ssh",
"-l",
"alice",
"-pw",
"secret",
]);
assert.equal(scriptLikeHost?.hostname, "prod.js");
assert.equal(scriptLikeHost?.url, "ssh://alice:secret@prod.js");
const fromElectron = parsePuttyCommandLine([
"electron",
"main.cjs",
"-ssh",
"alice@host.example",
"-pw",
"secret",
]);
assert.equal(fromElectron?.hostname, "host.example");
assert.equal(fromElectron?.url, "ssh://alice:secret@host.example");
});
test("redactPuttyCommandLinePasswords masks -pw values in argv", () => {
const argv = ["Netcatty.exe", "-ssh", "alice@host", "-pw", "s3cret!!"];
redactPuttyCommandLinePasswords(argv);
assert.equal(argv[4], "********");
});