Compare commits

...
8 changed files with 737 additions and 5 deletions

No files matched your search

+29 -3
View File
@@ -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,
+184
View File
@@ -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: [] });
});
+4
View File
@@ -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.
+8 -2
View File
@@ -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);
});
+5
View File
@@ -268,6 +268,11 @@ function redactPuttyCommandLinePasswords(argv) {
}
module.exports = {
PUTTY_VALUE_FLAGS: VALUE_FLAGS,
parseHostSpec,
isElectronNoiseArg,
parsePort,
parsePuttyCommandLine,
redactPuttyCommandLinePasswords,
toDeepLinkUrl,
};
+246
View File
@@ -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,
};
+239
View File
@@ -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");
});