mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a65c86d89a | ||
|
|
4428f978f3 | ||
|
|
af33e7432b | ||
|
|
e2c829b223 | ||
|
|
3fbda97efd | ||
|
|
5d61240211 | ||
|
|
2edd1ffeda | ||
|
|
217f71f1c7 | ||
|
|
a3c5cc7596 | ||
|
|
fe0af1ac10 |
No files matched your search
+29
-3
@@ -4,6 +4,10 @@ const {
|
||||
parsePuttyCommandLine,
|
||||
redactPuttyCommandLinePasswords,
|
||||
} = require("./puttyCommandLine.cjs");
|
||||
const {
|
||||
parseSecureCrtCommandLineTokens,
|
||||
redactSecureCrtCommandLinePasswords,
|
||||
} = require("./secureCrtCommandLine.cjs");
|
||||
|
||||
const SSH_DEEP_LINK_CHANNEL = "netcatty:deepLink:ssh";
|
||||
const TELNET_DEEP_LINK_CHANNEL = "netcatty:deepLink:telnet";
|
||||
@@ -19,9 +23,25 @@ function isDeepLinkUrl(rawUrl, protocol) {
|
||||
return rawUrl.trim().toLowerCase().startsWith(`${protocol}://`);
|
||||
}
|
||||
|
||||
function collectDeepLinkUrls(argv, protocol) {
|
||||
// Known option values are never standalone URLs, even when parsing fails.
|
||||
// On success, also exclude the remaining consumed launch tokens.
|
||||
function collectSchemeUrlCandidates(argv) {
|
||||
if (!Array.isArray(argv)) return [];
|
||||
return argv.filter((rawUrl) => isDeepLinkUrl(rawUrl, protocol));
|
||||
const tokens = parseSecureCrtCommandLineTokens(argv);
|
||||
if (!tokens) return argv;
|
||||
const filterIndices = new Set();
|
||||
if (tokens.operandIndices instanceof Set) {
|
||||
for (const index of tokens.operandIndices) filterIndices.add(index);
|
||||
}
|
||||
if (tokens.result && tokens.consumedIndices instanceof Set) {
|
||||
for (const index of tokens.consumedIndices) filterIndices.add(index);
|
||||
}
|
||||
if (filterIndices.size === 0) return argv;
|
||||
return argv.filter((_, index) => !filterIndices.has(index));
|
||||
}
|
||||
|
||||
function collectDeepLinkUrls(argv, protocol) {
|
||||
return collectSchemeUrlCandidates(argv).filter((rawUrl) => isDeepLinkUrl(rawUrl, protocol));
|
||||
}
|
||||
|
||||
function isSshDeepLinkUrl(rawUrl) {
|
||||
@@ -57,7 +77,12 @@ function collectPuttyStyleDeepLinkUrls(argv) {
|
||||
return { ssh: [], telnet: [] };
|
||||
}
|
||||
|
||||
const parsed = parsePuttyCommandLine(argv);
|
||||
// SecureCRT-style switches (/SSH2 /L user /P 22 /PASSWORD pass host) are
|
||||
// tried first: bastion/4A launchers configured as "SecureCRT" emit them, and
|
||||
// the flag sets are disjoint from PuTTY-style dashes, so trying SecureCRT
|
||||
// first then falling back to PuTTY covers both callers (#3390, #3044).
|
||||
const secureCrt = parseSecureCrtCommandLineTokens(argv);
|
||||
const parsed = secureCrt ? secureCrt.result : parsePuttyCommandLine(argv);
|
||||
if (!parsed?.url) return { ssh: [], telnet: [] };
|
||||
if (parsed.protocol === TELNET_PROTOCOL) {
|
||||
return { ssh: [], telnet: [parsed.url] };
|
||||
@@ -441,6 +466,7 @@ module.exports = {
|
||||
collectSshDeepLinkUrls,
|
||||
collectTelnetDeepLinkUrls,
|
||||
redactPuttyCommandLinePasswords,
|
||||
redactSecureCrtCommandLinePasswords,
|
||||
isJmsDeepLinkUrl,
|
||||
isSshDeepLinkUrl,
|
||||
isTelnetDeepLinkUrl,
|
||||
|
||||
@@ -92,6 +92,137 @@ test("collectSshDeepLinkQueueItems keeps PuTTY CLI launches when scheme URLs are
|
||||
);
|
||||
});
|
||||
|
||||
test("collectPuttyStyleDeepLinkUrls converts SecureCRT-style argv", () => {
|
||||
assert.deepEqual(
|
||||
collectPuttyStyleDeepLinkUrls([
|
||||
String.raw`C:\Program Files\Netcatty\Netcatty.exe`,
|
||||
"/SSH2",
|
||||
"/L",
|
||||
"alice",
|
||||
"/P",
|
||||
"2222",
|
||||
"/PASSWORD",
|
||||
"s3cret",
|
||||
"10.0.0.8",
|
||||
]),
|
||||
{ ssh: ["ssh://alice:s3cret@10.0.0.8:2222"], telnet: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test("collectPuttyStyleDeepLinkUrls routes SecureCRT /TELNET to the telnet queue", () => {
|
||||
assert.deepEqual(
|
||||
collectPuttyStyleDeepLinkUrls([
|
||||
"Netcatty.exe",
|
||||
"/TELNET",
|
||||
"old.example.com",
|
||||
"/P",
|
||||
"2323",
|
||||
]),
|
||||
{ ssh: [], telnet: ["telnet://old.example.com:2323"] },
|
||||
);
|
||||
});
|
||||
|
||||
test("collectSshDeepLinkQueueItems keeps SecureCRT CLI launches when scheme URLs are disabled", () => {
|
||||
assert.deepEqual(
|
||||
collectSshDeepLinkQueueItems([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/L",
|
||||
"alice",
|
||||
"/P",
|
||||
"2222",
|
||||
"/PASSWORD",
|
||||
"s3cret",
|
||||
"10.0.0.8",
|
||||
], { includeSchemeUrls: false }),
|
||||
{ ssh: [{ rawUrl: "ssh://alice:s3cret@10.0.0.8:2222", viaCommandLine: true }], telnet: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test("collectPuttyStyleDeepLinkUrls connects SecureCRT launches whose password looks like a scheme URL", () => {
|
||||
// (#3391) A /PASSWORD value starting with ssh:// must never win the
|
||||
// scheme-token early return: the whole CLI launch would be dropped.
|
||||
assert.deepEqual(
|
||||
collectPuttyStyleDeepLinkUrls([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/L",
|
||||
"alice",
|
||||
"/PASSWORD",
|
||||
"ssh://s3cret",
|
||||
"10.0.0.8",
|
||||
]),
|
||||
{ ssh: ["ssh://alice:ssh%3A%2F%2Fs3cret@10.0.0.8"], telnet: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test("collectSshDeepLinkQueueItems does not queue SecureCRT password values as scheme links", () => {
|
||||
// (#3391) With scheme handling enabled, the password operand must not be
|
||||
// queued as a standalone ssh:// link to host "s3cret".
|
||||
assert.deepEqual(
|
||||
collectSshDeepLinkQueueItems([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/L",
|
||||
"alice",
|
||||
"/PASSWORD",
|
||||
"ssh://s3cret",
|
||||
"10.0.0.8",
|
||||
], { includeSchemeUrls: true }),
|
||||
{ ssh: [{ rawUrl: "ssh://alice:ssh%3A%2F%2Fs3cret@10.0.0.8", viaCommandLine: true }], telnet: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test("collectSshDeepLinkQueueItems filters password operands even when the SecureCRT parse fails", () => {
|
||||
// (#3391) `/P 99999` makes the launch unparseable, but the /PASSWORD value
|
||||
// was already consumed as a credential: it must not be queued as a
|
||||
// standalone ssh:// link to host "s3cret".
|
||||
assert.deepEqual(
|
||||
collectSshDeepLinkQueueItems([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/PASSWORD",
|
||||
"ssh://s3cret",
|
||||
"/P",
|
||||
"99999",
|
||||
"10.0.0.8",
|
||||
], { includeSchemeUrls: true }),
|
||||
{ ssh: [], telnet: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test("collectSshDeepLinkQueueItems keeps genuine scheme links alongside a failed SecureCRT parse", () => {
|
||||
// A failed parse must only filter credential operands, not every consumed
|
||||
// index, so genuine scheme links in the same argv still queue.
|
||||
assert.deepEqual(
|
||||
collectSshDeepLinkQueueItems([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/PASSWORD",
|
||||
"s3cret",
|
||||
"/P",
|
||||
"99999",
|
||||
"10.0.0.8",
|
||||
"ssh://bob@example.com",
|
||||
], { includeSchemeUrls: true }),
|
||||
{ ssh: [{ rawUrl: "ssh://bob@example.com", viaCommandLine: false }], telnet: [] },
|
||||
);
|
||||
});
|
||||
|
||||
for (const url of ["ssh://bob@example.com", "ssh://example.com", "telnet://example.com", " ssh://bob@example.com "]) {
|
||||
test(`standalone scheme link ${url} never inherits SecureCRT credentials`, () => {
|
||||
const argv = ["Netcatty.exe", "/SSH2", "/L", "alice", "/PASSWORD",
|
||||
"secret", "10.0.0.8", url];
|
||||
const protocol = new URL(url).protocol === "ssh:" ? "ssh" : "telnet";
|
||||
const expected = { ssh: [], telnet: [] };
|
||||
expected[protocol].push({ rawUrl: url, viaCommandLine: false });
|
||||
assert.deepEqual(collectSshDeepLinkQueueItems(argv), expected);
|
||||
assert.deepEqual(collectSshDeepLinkQueueItems(argv, { includeSchemeUrls: false }), {
|
||||
ssh: [], telnet: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("collectSshDeepLinkQueueItems keeps scheme URL gating separate from CLI launches", () => {
|
||||
const queueItems = collectSshDeepLinkQueueItems(
|
||||
["/Applications/Netcatty.app/Contents/MacOS/Netcatty", "ssh://alice@example.com"],
|
||||
@@ -514,3 +645,56 @@ test("applyInitialJmsDeepLinkPreference does not warn when disabled startup remo
|
||||
assert.equal(cleared, false);
|
||||
assert.equal(warnings.length, 0);
|
||||
});
|
||||
|
||||
for (const flag of ["/L", "/P", "/PASSWORD", "/PASSPHRASE", "/AUTH", "/I", "/S", "/N", "/TITLEBAR", "/LOG", "/LOGAPPEND", "/FIREWALL", "/FWFIREWALL", "/PROXY"]) {
|
||||
for (const scheme of ["ssh", "telnet", "jms"]) {
|
||||
test(`malformed SecureCRT launch filters ${flag} ${scheme} operands`, () => {
|
||||
const argv = ["Netcatty.exe", "/SSH2", "/P", "99999", flag,
|
||||
`${scheme}://option-value`, "real.example.com"];
|
||||
assert.deepEqual(collectSshDeepLinkQueueItems(argv), { ssh: [], telnet: [] });
|
||||
assert.deepEqual(collectJmsDeepLinkUrls(argv), []);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("failed SecureCRT launch cannot reinterpret its password as a PuTTY switch", () => {
|
||||
const argv = ["Netcatty.exe", "/SSH2", "/L", "alice", "/P", "99999", "/PASSWORD", "-ssh", "server.example.com"];
|
||||
assert.deepEqual(collectSshDeepLinkQueueItems(argv), { ssh: [], telnet: [] });
|
||||
});
|
||||
|
||||
for (const password of ["/SSH2", "/TELNET", "/PASSWORD", "/PASSPHRASE"]) {
|
||||
test(`PuTTY password ${password} does not select SecureCRT parsing`, () => {
|
||||
assert.deepEqual(collectSshDeepLinkQueueItems([
|
||||
"Netcatty.exe", "-ssh", "-l", "alice", "-P", "2222", "-pw", password, "server.example.com",
|
||||
]), { ssh: [{ rawUrl: `ssh://alice:${encodeURIComponent(password)}@server.example.com:2222`, viaCommandLine: true }], telnet: [] });
|
||||
});
|
||||
}
|
||||
|
||||
test("flag-shaped SecureCRT passwords do not consume genuine scheme links", () => {
|
||||
assert.deepEqual(collectSshDeepLinkQueueItems([
|
||||
"Netcatty.exe", "/SSH2", "/PASSWORD", "/L", "ssh://bob@example.com",
|
||||
]), { ssh: [{ rawUrl: "ssh://bob@example.com", viaCommandLine: false }], telnet: [] });
|
||||
});
|
||||
|
||||
test("ambiguous SecureCRT destinations are rejected instead of guessing a host", () => {
|
||||
for (const names of [["Device", "bastion"], ["device.example.com", "bastion"]]) {
|
||||
assert.deepEqual(collectSshDeepLinkQueueItems([
|
||||
"Netcatty.exe", "/T", names[0], "/SSH2", names[1], "/L", "alice", "/PASSWORD", "secret",
|
||||
]), { ssh: [], telnet: [] });
|
||||
}
|
||||
});
|
||||
|
||||
test("SecureCRT launches reject mixed single-dash client switches", () => {
|
||||
for (const flag of ["-serial", "-raw", "-telnet", "-ssh", "-unknown"]) {
|
||||
assert.deepEqual(collectSshDeepLinkQueueItems([
|
||||
"Netcatty.exe", "/SSH2", flag, "host", "/L", "alice", "/PASSWORD", "secret",
|
||||
]), { ssh: [], telnet: [] });
|
||||
}
|
||||
});
|
||||
|
||||
test("SecureCRT launches preserve dash-shaped values and Electron switches", () => {
|
||||
assert.deepEqual(collectSshDeepLinkQueueItems([
|
||||
"Netcatty.exe", "--original-process-start-time=1", "/SSH2", "host",
|
||||
"/TITLEBAR", "-serial", "/L", "-raw", "/PASSWORD", "-telnet",
|
||||
]), { ssh: [{ rawUrl: "ssh://-raw:-telnet@host", viaCommandLine: true }], telnet: [] });
|
||||
});
|
||||
@@ -76,6 +76,7 @@ const {
|
||||
collectSshDeepLinkQueueItems,
|
||||
getSshDeepLinkRendererReadyTimeoutMs,
|
||||
redactPuttyCommandLinePasswords,
|
||||
redactSecureCrtCommandLinePasswords,
|
||||
isJmsDeepLinkUrl,
|
||||
isSshDeepLinkUrl,
|
||||
isTelnetDeepLinkUrl,
|
||||
@@ -705,6 +706,8 @@ const pendingTelnetDeepLinkUrls = [...initialDeepLinkQueueItems.telnet];
|
||||
// redacted copy would make warm PuTTY-style launches authenticate with the
|
||||
// masked password.
|
||||
const rawLaunchArgvForHandoff = [...process.argv];
|
||||
// SecureCRT operands may contain PuTTY switch names; scrub them first.
|
||||
redactSecureCrtCommandLinePasswords(process.argv);
|
||||
redactPuttyCommandLinePasswords(process.argv);
|
||||
const pendingOpenTerminalPaths = resolveOpenTerminalPathsFromArgs(process.argv);
|
||||
let flushingSshDeepLinks = false;
|
||||
@@ -1243,6 +1246,7 @@ if (!gotLock) {
|
||||
const deepLinkQueueItems = collectSshDeepLinkQueueItems(secondInstanceArgv, {
|
||||
includeSchemeUrls: sshDeepLinkEnabled,
|
||||
});
|
||||
redactSecureCrtCommandLinePasswords(secondInstanceArgv);
|
||||
redactPuttyCommandLinePasswords(secondInstanceArgv);
|
||||
if (rawLaunchArgv) {
|
||||
// Parsing and subsequent routing use the independent ordered copy.
|
||||
|
||||
@@ -7,6 +7,7 @@ const vm = require("node:vm");
|
||||
const {
|
||||
collectSshDeepLinkQueueItems,
|
||||
redactPuttyCommandLinePasswords,
|
||||
redactSecureCrtCommandLinePasswords,
|
||||
} = require("./deepLink.cjs");
|
||||
|
||||
test("second instance forwards its raw argv through the single-instance lock", () => {
|
||||
@@ -80,8 +81,9 @@ test("failed protocol registration keeps command-line launch intents queued", ()
|
||||
assert.match(callSource, /dropSchemePendingDeepLinks\(\)/);
|
||||
});
|
||||
|
||||
for (const secureCrt of [false, true]) {
|
||||
for (const gotLock of [true, false]) {
|
||||
test(`launch password snapshot is released after handoff (primary=${gotLock})`, () => {
|
||||
test(`launch password snapshot is released after handoff (primary=${gotLock}, secureCrt=${secureCrt})`, () => {
|
||||
const source = readFileSync(path.join(__dirname, "main.cjs"), "utf8");
|
||||
const snapshotStart = source.indexOf("const rawLaunchArgvForHandoff");
|
||||
const snapshotEnd = source.indexOf("const pendingOpenTerminalPaths", snapshotStart);
|
||||
@@ -89,11 +91,14 @@ for (const gotLock of [true, false]) {
|
||||
const lockEnd = source.indexOf("if (!gotLock)", lockStart);
|
||||
assert.ok(snapshotStart >= 0 && snapshotEnd > snapshotStart);
|
||||
assert.ok(lockStart >= 0 && lockEnd > lockStart);
|
||||
const argv = ["netcatty", "-ssh", "alice@localhost", "-pw", "test password:@"];
|
||||
const argv = secureCrt
|
||||
? ["netcatty", "/SSH2", "localhost", "/L", "-pw", "/PASSWORD", "test password:@"]
|
||||
: ["netcatty", "-ssh", "alice@localhost", "-pw", "test password:@"];
|
||||
let handedOff;
|
||||
const context = vm.createContext({
|
||||
process: { argv },
|
||||
redactPuttyCommandLinePasswords,
|
||||
redactSecureCrtCommandLinePasswords,
|
||||
app: {
|
||||
requestSingleInstanceLock(data) {
|
||||
// Electron serializes additionalData synchronously in this call.
|
||||
@@ -112,3 +117,4 @@ for (const gotLock of [true, false]) {
|
||||
assert.equal(context.remainingArgs, 0, "do not retain a plaintext handoff snapshot");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -82,3 +82,25 @@ test("Explorer launch routing uses the ordered copy after transport cleanup", ()
|
||||
assert.equal(eventArgv.length, 0);
|
||||
assert.equal(additionalData.rawLaunchArgv.length, 0);
|
||||
});
|
||||
|
||||
for (const useHandoff of [true, false]) {
|
||||
test(`reported SecureCRT 4A launch reaches the bastion and clears credentials (handoff=${useHandoff})`, () => {
|
||||
const h = createHarness();
|
||||
const args = ["/TITLEBAR", "192.0.2.10", "/N", "192.0.2.10", "/T", "/SSH2",
|
||||
"198.51.100.20", "/P", "2200", "/L", "alice", "/PASSWORD", "space :/@ password"];
|
||||
const argv = ["netcatty", ...args];
|
||||
const data = useHandoff ? { rawLaunchArgv: [...args] } : undefined;
|
||||
h.handle(null, argv, "/working-directory", data);
|
||||
assert.deepEqual(h.queued, ["ssh://alice:space%20%3A%2F%40%20password@198.51.100.20:2200"]);
|
||||
assert.equal(argv.includes("space :/@ password"), false);
|
||||
if (data) assert.equal(data.rawLaunchArgv.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("legacy SecureCRT startup clears passwords after PuTTY-shaped metadata", () => {
|
||||
const h = createHarness();
|
||||
const argv = ["netcatty", "/SSH2", "/L", "-pw", "/TITLEBAR", "-pw", "/PASSWORD", "secret", "server.example.com"];
|
||||
h.handle(null, argv, "/working-directory");
|
||||
assert.deepEqual(h.queued, ["ssh://-pw:secret@server.example.com"]);
|
||||
assert.equal(argv.includes("secret"), false);
|
||||
});
|
||||
@@ -268,6 +268,11 @@ function redactPuttyCommandLinePasswords(argv) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PUTTY_VALUE_FLAGS: VALUE_FLAGS,
|
||||
parseHostSpec,
|
||||
isElectronNoiseArg,
|
||||
parsePort,
|
||||
parsePuttyCommandLine,
|
||||
redactPuttyCommandLinePasswords,
|
||||
toDeepLinkUrl,
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
const {
|
||||
PUTTY_VALUE_FLAGS,
|
||||
isElectronNoiseArg,
|
||||
parseHostSpec,
|
||||
parsePort,
|
||||
toDeepLinkUrl,
|
||||
} = require("./puttyCommandLine.cjs");
|
||||
|
||||
const SSH_PROTOCOL = "ssh";
|
||||
const TELNET_PROTOCOL = "telnet";
|
||||
|
||||
// SecureCRT-style protocol switches, e.g. `/SSH2 /L user /P 22 /PASSWORD pass
|
||||
// host` (case-insensitive). 4A / PAM bastion launchers that let the operator
|
||||
// pick a "SecureCRT" client emit exactly this shape, so Netcatty accepts it
|
||||
// and funnels the result through the same ssh:// deep-link queue as PuTTY-style
|
||||
// argv (#3390).
|
||||
const PROTOCOL_FLAGS = new Map([
|
||||
["/ssh", SSH_PROTOCOL],
|
||||
["/ssh1", SSH_PROTOCOL],
|
||||
["/ssh2", SSH_PROTOCOL],
|
||||
["/telnet", TELNET_PROTOCOL],
|
||||
]);
|
||||
|
||||
// Protocol switches Netcatty cannot map to a connection; bail out instead of
|
||||
// silently connecting over a different transport.
|
||||
const UNSUPPORTED_PROTOCOL_FLAGS = new Set([
|
||||
"/serial",
|
||||
"/rlogin",
|
||||
"/tapi",
|
||||
]);
|
||||
|
||||
// Switches whose next argv token is a value. Most are accepted but ignored:
|
||||
// Netcatty has no SecureCRT session database, identity file, auth-method or
|
||||
// logging concept, so a 4A line that carries them still connects via the host.
|
||||
const VALUE_FLAGS = new Set([
|
||||
"/l", // login username
|
||||
"/p", // port
|
||||
"/password",
|
||||
// Consumed and ignored:
|
||||
"/auth", // keyboard-interactive | password | publickey | gssapi | tacacs
|
||||
"/i", // identity (private key) file path
|
||||
"/passphrase",
|
||||
"/s", // saved SecureCRT session name
|
||||
"/n", // tab name
|
||||
"/titlebar", // window title
|
||||
"/log",
|
||||
"/logappend",
|
||||
"/firewall",
|
||||
"/fwfirewall",
|
||||
"/proxy",
|
||||
]);
|
||||
|
||||
const PORT_FLAGS = new Set(["/p"]);
|
||||
const USERNAME_FLAGS = new Set(["/l"]);
|
||||
const PASSWORD_FLAGS = new Set(["/password"]);
|
||||
const IGNORED_VALUE_FLAGS = new Set([
|
||||
"/auth", "/i", "/passphrase", "/s", "/n", "/titlebar",
|
||||
"/log", "/logappend", "/firewall", "/fwfirewall", "/proxy",
|
||||
]);
|
||||
// Standalone switches (no separate value token).
|
||||
const SKIP_FLAGS = new Set([
|
||||
"/t", // open in a tab; /N supplies the tab name
|
||||
"/new",
|
||||
"/x", "/c", "/v", "/a", "/z",
|
||||
]);
|
||||
|
||||
const PASSWORD_REDACT_FLAGS = new Set(["/password", "/passphrase"]);
|
||||
|
||||
function normalizeFlag(arg) {
|
||||
return typeof arg === "string" ? arg.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function hasSecureCrtLaunchSignal(argv) {
|
||||
if (!Array.isArray(argv)) return false;
|
||||
for (let index = 1; index < argv.length; index += 1) {
|
||||
const flag = normalizeFlag(argv[index]);
|
||||
if (PROTOCOL_FLAGS.has(flag) || PASSWORD_REDACT_FLAGS.has(flag)) return true;
|
||||
if (VALUE_FLAGS.has(flag) || PUTTY_VALUE_FLAGS.has(argv[index])) index += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Identify every known option operand before validating values, so an early
|
||||
// parse failure cannot turn a later option value into a standalone scheme URL.
|
||||
// Skip operands: a password equal to "/L" is data, not another switch.
|
||||
function findOperandIndices(argv) {
|
||||
const operandIndices = new Set();
|
||||
const credentialIndices = new Set();
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const flag = normalizeFlag(argv[index]);
|
||||
if (!VALUE_FLAGS.has(flag) || typeof argv[index + 1] !== "string") continue;
|
||||
operandIndices.add(index + 1);
|
||||
if (PASSWORD_REDACT_FLAGS.has(flag) || USERNAME_FLAGS.has(flag)) {
|
||||
credentialIndices.add(index + 1);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return { operandIndices, credentialIndices };
|
||||
}
|
||||
|
||||
// Consumed indices include positionals only on success. Operand indices remain
|
||||
// valid on failure and must always be excluded from standalone URL scanning.
|
||||
function parseSecureCrtCommandLineTokens(argv) {
|
||||
if (!Array.isArray(argv) || !hasSecureCrtLaunchSignal(argv)) return null;
|
||||
|
||||
let protocol = SSH_PROTOCOL;
|
||||
let username;
|
||||
let password;
|
||||
let port;
|
||||
const positionals = [];
|
||||
const consumedIndices = new Set();
|
||||
const { operandIndices, credentialIndices } = findOperandIndices(argv);
|
||||
const fail = () => ({ result: null, consumedIndices, operandIndices, credentialIndices });
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (typeof arg !== "string" || !arg) continue;
|
||||
const flag = normalizeFlag(arg);
|
||||
|
||||
if (UNSUPPORTED_PROTOCOL_FLAGS.has(flag)) return fail();
|
||||
|
||||
const protocolFromFlag = PROTOCOL_FLAGS.get(flag);
|
||||
if (protocolFromFlag) {
|
||||
protocol = protocolFromFlag;
|
||||
consumedIndices.add(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SKIP_FLAGS.has(flag)) {
|
||||
consumedIndices.add(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VALUE_FLAGS.has(flag)) {
|
||||
const value = argv[index + 1];
|
||||
if (typeof value !== "string") return fail();
|
||||
consumedIndices.add(index);
|
||||
consumedIndices.add(index + 1);
|
||||
index += 1;
|
||||
if (PORT_FLAGS.has(flag)) {
|
||||
const parsedPort = parsePort(value);
|
||||
if (parsedPort === null) return fail();
|
||||
port = parsedPort;
|
||||
continue;
|
||||
}
|
||||
if (USERNAME_FLAGS.has(flag)) {
|
||||
// `index` now points at the operand value (it was advanced above).
|
||||
credentialIndices.add(index);
|
||||
const nextUser = value.trim();
|
||||
if (!nextUser) return fail();
|
||||
username = nextUser;
|
||||
continue;
|
||||
}
|
||||
if (PASSWORD_FLAGS.has(flag)) {
|
||||
credentialIndices.add(index);
|
||||
if (value === "") return fail();
|
||||
password = value;
|
||||
continue;
|
||||
}
|
||||
if (!IGNORED_VALUE_FLAGS.has(flag) || !value.trim()) return fail();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only the executable and Electron development entry point are paths;
|
||||
// other slash-prefixed tokens are switches, including punctuated names.
|
||||
const isEntryPoint = index === 1
|
||||
&& /(?:^|[/\\])electron(?:\.exe)?$/i.test(argv[0])
|
||||
&& /\.(?:js|cjs|mjs|asar)$/i.test(arg);
|
||||
if (index > 0 && !isEntryPoint && arg.startsWith("/")) return fail();
|
||||
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(arg.trim())) continue;
|
||||
|
||||
if (isElectronNoiseArg(arg, index, argv)) {
|
||||
consumedIndices.add(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single-dash client switches are not part of SecureCRT syntax.
|
||||
// Do not silently reinterpret a mixed-protocol launch as SSH.
|
||||
if (arg.startsWith("-")) return fail();
|
||||
|
||||
const spec = parseHostSpec(arg);
|
||||
if (spec) {
|
||||
positionals.push(spec);
|
||||
consumedIndices.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
// A launch describes one destination. Do not guess between multiple hosts
|
||||
// and risk sending credentials to metadata or an unsupported option value.
|
||||
if (positionals.length !== 1) return fail();
|
||||
|
||||
const hostSpec = positionals[0];
|
||||
|
||||
const resolvedUsername = (username || hostSpec.username || "").trim() || undefined;
|
||||
const resolvedPort = port ?? hostSpec.port;
|
||||
const hostname = hostSpec.hostname;
|
||||
if (!hostname) return fail();
|
||||
|
||||
const url = toDeepLinkUrl({
|
||||
protocol,
|
||||
username: resolvedUsername,
|
||||
password,
|
||||
hostname,
|
||||
port: resolvedPort,
|
||||
});
|
||||
|
||||
return {
|
||||
result: {
|
||||
protocol,
|
||||
url,
|
||||
hostname,
|
||||
...(resolvedUsername ? { username: resolvedUsername } : {}),
|
||||
...(password !== undefined ? { password } : {}),
|
||||
...(resolvedPort ? { port: resolvedPort } : {}),
|
||||
},
|
||||
consumedIndices,
|
||||
operandIndices,
|
||||
credentialIndices,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSecureCrtCommandLine(argv) {
|
||||
return parseSecureCrtCommandLineTokens(argv)?.result ?? null;
|
||||
}
|
||||
|
||||
function redactSecureCrtCommandLinePasswords(argv) {
|
||||
if (!Array.isArray(argv)) return argv;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const flag = normalizeFlag(argv[index]);
|
||||
if (!VALUE_FLAGS.has(flag) && !PUTTY_VALUE_FLAGS.has(argv[index])) continue;
|
||||
const next = argv[index + 1];
|
||||
if (typeof next !== "string") continue;
|
||||
if (PASSWORD_REDACT_FLAGS.has(flag)) {
|
||||
argv[index + 1] = "*".repeat(Math.min(next.length, 8)) || "********";
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return argv;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseSecureCrtCommandLine,
|
||||
parseSecureCrtCommandLineTokens,
|
||||
redactSecureCrtCommandLinePasswords,
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
parseSecureCrtCommandLine,
|
||||
parseSecureCrtCommandLineTokens,
|
||||
redactSecureCrtCommandLinePasswords,
|
||||
} = require("./secureCrtCommandLine.cjs");
|
||||
|
||||
test("parseSecureCrtCommandLine accepts 4A-style /SSH2 launch", () => {
|
||||
assert.deepEqual(
|
||||
parseSecureCrtCommandLine([
|
||||
String.raw`C:\Program Files\Netcatty\Netcatty.exe`,
|
||||
"/SSH2",
|
||||
"/L",
|
||||
"alice",
|
||||
"/P",
|
||||
"2222",
|
||||
"/PASSWORD",
|
||||
"s3cret",
|
||||
"10.0.0.8",
|
||||
]),
|
||||
{
|
||||
protocol: "ssh",
|
||||
url: "ssh://alice:s3cret@10.0.0.8:2222",
|
||||
hostname: "10.0.0.8",
|
||||
username: "alice",
|
||||
password: "s3cret",
|
||||
port: 2222,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSecureCrtCommandLine accepts PAM-style /T /N /SSH2 line", () => {
|
||||
const parsed = parseSecureCrtCommandLine([
|
||||
"Netcatty.exe",
|
||||
"/T",
|
||||
"/N",
|
||||
"Device",
|
||||
"/SSH2",
|
||||
"/L",
|
||||
"root",
|
||||
"192.168.10.2",
|
||||
"/P",
|
||||
"22",
|
||||
]);
|
||||
assert.equal(parsed?.url, "ssh://root@192.168.10.2:22");
|
||||
assert.equal(parsed?.username, "root");
|
||||
assert.equal(parsed?.password, undefined);
|
||||
assert.equal(parsed?.port, 22);
|
||||
});
|
||||
|
||||
test("parseSecureCrtCommandLine accepts case-insensitive flags and user@host positional", () => {
|
||||
const parsed = parseSecureCrtCommandLine([
|
||||
"Netcatty.exe",
|
||||
"/ssh2",
|
||||
"/password",
|
||||
"pw",
|
||||
"bob@host.example.com",
|
||||
]);
|
||||
assert.equal(parsed?.url, "ssh://bob:pw@host.example.com");
|
||||
assert.equal(parsed?.username, "bob");
|
||||
assert.equal(parsed?.hostname, "host.example.com");
|
||||
});
|
||||
|
||||
test("parseSecureCrtCommandLine accepts /TELNET", () => {
|
||||
const parsed = parseSecureCrtCommandLine([
|
||||
"Netcatty.exe",
|
||||
"/TELNET",
|
||||
"old.example.com",
|
||||
"/P",
|
||||
"2323",
|
||||
]);
|
||||
assert.equal(parsed?.protocol, "telnet");
|
||||
assert.equal(parsed?.url, "telnet://old.example.com:2323");
|
||||
});
|
||||
|
||||
test("parseSecureCrtCommandLine consumes and ignores auth, identity and session values", () => {
|
||||
const parsed = parseSecureCrtCommandLine([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/AUTH",
|
||||
"keyboard-interactive",
|
||||
"/I",
|
||||
String.raw`C:\keys\id_rsa`,
|
||||
"/S",
|
||||
"Production",
|
||||
"/L",
|
||||
"ops",
|
||||
"/PASSWORD",
|
||||
"hunter2",
|
||||
"bastion.example.com",
|
||||
]);
|
||||
assert.equal(parsed?.url, "ssh://ops:hunter2@bastion.example.com");
|
||||
});
|
||||
|
||||
test("parseSecureCrtCommandLine rejects unsupported protocols and missing values", () => {
|
||||
assert.equal(parseSecureCrtCommandLine(["Netcatty.exe", "/SERIAL", "com1"]), null);
|
||||
assert.equal(parseSecureCrtCommandLine(["Netcatty.exe", "/SSH2", "/L"]), null);
|
||||
assert.equal(parseSecureCrtCommandLine(["Netcatty.exe", "/SSH2", "/P", "99999"]), null);
|
||||
assert.equal(parseSecureCrtCommandLine(["Netcatty.exe", "/SSH2", "/PASSWORD", ""]), null);
|
||||
assert.equal(parseSecureCrtCommandLine(["Netcatty.exe", "-ssh", "user@host", "-pw", "x"]), null);
|
||||
});
|
||||
|
||||
test("parseSecureCrtCommandLineTokens reports consumed operand indices", () => {
|
||||
const { result, consumedIndices } = parseSecureCrtCommandLineTokens([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/L",
|
||||
"alice",
|
||||
"/PASSWORD",
|
||||
"ssh://s3cret",
|
||||
"10.0.0.8",
|
||||
]);
|
||||
assert.equal(result?.url, "ssh://alice:ssh%3A%2F%2Fs3cret@10.0.0.8");
|
||||
// Every token is an operand of the command line, including the password
|
||||
// value that merely starts with a scheme (#3391).
|
||||
assert.deepEqual([...consumedIndices].sort((a, b) => a - b), [0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
|
||||
test("parseSecureCrtCommandLineTokens returns consumed indices with a null result on failure", () => {
|
||||
const { result, consumedIndices } = parseSecureCrtCommandLineTokens([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/PASSWORD",
|
||||
"ssh://s3cret",
|
||||
"/SERIAL",
|
||||
"com1",
|
||||
]);
|
||||
assert.equal(result, null);
|
||||
assert.deepEqual([...consumedIndices].sort((a, b) => a - b), [0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
test("parseSecureCrtCommandLineTokens reports credential indices even when the parse fails", () => {
|
||||
const { result, consumedIndices, credentialIndices } = parseSecureCrtCommandLineTokens([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/PASSWORD",
|
||||
"ssh://s3cret",
|
||||
"/P",
|
||||
"99999",
|
||||
"10.0.0.8",
|
||||
]);
|
||||
assert.equal(result, null);
|
||||
// The parse fails on the invalid port, so the host positional was never
|
||||
// consumed, but the password operand is still reported as a credential (#3391).
|
||||
assert.deepEqual([...consumedIndices].sort((a, b) => a - b), [0, 1, 2, 3, 4, 5]);
|
||||
assert.deepEqual([...credentialIndices].sort((a, b) => a - b), [3]);
|
||||
});
|
||||
|
||||
test("parseSecureCrtCommandLineTokens reports credential operands after a pre-credential parse failure", () => {
|
||||
const { result, credentialIndices } = parseSecureCrtCommandLineTokens([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/P",
|
||||
"99999",
|
||||
"/PASSWORD",
|
||||
"ssh://secret",
|
||||
"real.example.com",
|
||||
]);
|
||||
assert.equal(result, null);
|
||||
// The invalid /P value fails the parse before /PASSWORD is visited, but the
|
||||
// password operand must still be filtered from scheme-URL scanning (#3391).
|
||||
assert.deepEqual([...credentialIndices].sort((a, b) => a - b), [5]);
|
||||
});
|
||||
|
||||
test("parseSecureCrtCommandLineTokens reports /PASSPHRASE operands as credentials on failure", () => {
|
||||
const { result, credentialIndices } = parseSecureCrtCommandLineTokens([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/PASSPHRASE",
|
||||
"ssh://secret",
|
||||
"/P",
|
||||
"99999",
|
||||
"10.0.0.8",
|
||||
]);
|
||||
assert.equal(result, null);
|
||||
assert.deepEqual([...credentialIndices].sort((a, b) => a - b), [3]);
|
||||
});
|
||||
|
||||
test("parseSecureCrtCommandLineTokens reports /L username operands as credentials on failure", () => {
|
||||
const { result, credentialIndices } = parseSecureCrtCommandLineTokens([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/P",
|
||||
"99999",
|
||||
"/L",
|
||||
"ssh://alice",
|
||||
"real.example.com",
|
||||
]);
|
||||
assert.equal(result, null);
|
||||
// The invalid /P value fails the parse before /L is visited, but the username
|
||||
// operand must still be filtered from scheme-URL scanning (#3391).
|
||||
assert.deepEqual([...credentialIndices].sort((a, b) => a - b), [5]);
|
||||
});
|
||||
|
||||
test("parseSecureCrtCommandLineTokens returns null without a SecureCRT launch signal", () => {
|
||||
assert.equal(parseSecureCrtCommandLineTokens(["Netcatty.exe", "ssh://alice@example.com"]), null);
|
||||
});
|
||||
|
||||
test("redactSecureCrtCommandLinePasswords masks /PASSWORD and /PASSPHRASE values", () => {
|
||||
const argv = ["Netcatty.exe", "/SSH2", "/PASSWORD", "s3cret", "/PASSPHRASE", "phrase", "host"];
|
||||
redactSecureCrtCommandLinePasswords(argv);
|
||||
assert.deepEqual(argv, ["Netcatty.exe", "/SSH2", "/PASSWORD", "******", "/PASSPHRASE", "******", "host"]);
|
||||
});
|
||||
|
||||
test("reported 4A launch uses the bastion address, never the title address", () => {
|
||||
const result = parseSecureCrtCommandLine([
|
||||
String.raw`C:\Program Files\Netcatty\Netcatty.exe`,
|
||||
"/TITLEBAR", "192.0.2.10", "/N", "192.0.2.10", "/T", "/SSH2", "198.51.100.20",
|
||||
"/P", "2200", "/L", "alice", "/PASSWORD", "example",
|
||||
]);
|
||||
assert.equal(result?.url, "ssh://alice:example@198.51.100.20:2200");
|
||||
});
|
||||
|
||||
test("unknown SecureCRT options cannot supply a host candidate", () => {
|
||||
assert.equal(parseSecureCrtCommandLine([
|
||||
"Netcatty.exe", "/SSH2", "/SCRIPT", "login.vbs", "/L", "alice", "/PASSWORD", "secret", "server",
|
||||
]), null);
|
||||
});
|
||||
|
||||
test("redaction does not mistake a flag-shaped username for a password switch", () => {
|
||||
const argv = ["Netcatty.exe", "/SSH2", "/L", "/PASSWORD", "/PASSWORD", "secret", "server.example.com"];
|
||||
redactSecureCrtCommandLinePasswords(argv);
|
||||
assert.deepEqual(argv, ["Netcatty.exe", "/SSH2", "/L", "/PASSWORD", "/PASSWORD", "******", "server.example.com"]);
|
||||
});
|
||||
|
||||
test("punctuated unknown SecureCRT switches are rejected", () => {
|
||||
for (const flag of ["/UNKNOWN-FLAG", "/UNKNOWN_FLAG", "/UNKNOWN=value", "/UNKNOWN:value", "/unknown/path"]) {
|
||||
assert.equal(parseSecureCrtCommandLine(["Netcatty.exe", "/SSH2", flag, "server.example.com"]), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("SecureCRT launches still accept absolute Electron development entry paths", () => {
|
||||
assert.equal(parseSecureCrtCommandLine([
|
||||
"/Applications/Electron.app/Contents/MacOS/Electron", "/workspace/electron/main.cjs",
|
||||
"/SSH2", "/L", "alice", "server.example.com",
|
||||
])?.url, "ssh://alice@server.example.com");
|
||||
});
|
||||
Reference in new issue
Block a user