Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 913293f839 fix(vault): reject CSV ProxyJump chains that contain a malformed hop
A multi-hop ProxyJump cell with any invalid hop now warns and leaves the
host chain unset, instead of saving the valid hops as a shortened route.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 09:40:06 +00:00
Cursor Agentand陈大猫 cbba6dcfa4 fix(vault): prefer a same-group label when binding CSV ProxyJump
Combine imported and existing label matches before pickCsvJumpCandidate so a cross-group imported label cannot beat an existing hop in the target group.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 09:05:56 +00:00
Cursor Agentand陈大猫 f98defead5 fix(vault): prefer a same-group hop when binding CSV ProxyJump
When an imported host and an existing vault host both match a hop
endpoint, pick the candidate in the target host's group before falling
back to the imported match.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 08:44:16 +00:00
Cursor Agentand陈大猫 6564d2c934 fix(vault): drop inline hops from a rejected ProxyJump chain
A self-referential ProxyJump list created unresolved hops before the
chain was skipped, so the import kept those hosts. Discard inline hosts
created for that rejected chain.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 08:43:58 +00:00
Cursor Agentand陈大猫 2d49600c07 Merge remote-tracking branch 'origin/main' into cursor/csv-jump-host-roundtrip-5b81
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 08:35:18 +00:00
Cursor Agentand陈大猫 29d7a3f2c0 fix(vault): round-trip jump host chains in CSV import/export
CSV export dropped hostChain, so a vault imported on another machine
lost every jump host. Persist the ordered chain as an OpenSSH
ProxyJump column and bind each hop on import.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 08:12:53 +00:00
9 changed files with 563 additions and 16 deletions

No files matched your search

@@ -33,6 +33,7 @@ import {
filterVaultImportKeyPassphrasesAgainstExisting,
mergeVaultImportIssues,
resolveVaultImportKeyPassphraseConflicts,
toVaultImportExistingHostRef,
type VaultImportFormat,
} from "../../domain/vaultImport";
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
@@ -175,6 +176,7 @@ export function useVaultImportHandlers({
files,
encoding: options?.encoding,
masterPassword: options?.masterPassword,
existingHosts: hostsRef.current.map(toVaultImportExistingHostRef),
signal,
onProgress: (progress) => {
if (!signal.aborted) updateProgress(progress);
+265 -10
View File
@@ -14,8 +14,9 @@ export {
buildVaultHostMergeKey,
} from "./vaultHostCreate";
import { parseQuickConnectInput } from "./quickConnect";
import { canServeAsSshJumpHost } from "./terminalProtocol";
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase, decodeCsvProxyJump } from "./vaultImport/csvCredentialFields";
import { isCsvDirectProxyValue, parseCsvProxy } from "./vaultImport/csvProxy";
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
import { decodeFinalShellPassword } from "./finalShellPassword";
@@ -100,6 +101,29 @@ const parseProxyJump = (value: string): ParsedJumpHost[] => {
.filter((h): h is ParsedJumpHost => h !== null);
};
/** Vault host fields needed to bind a CSV ProxyJump hop without copying secrets. */
export interface VaultImportExistingHostRef {
id: string;
label: string;
hostname: string;
port?: number;
username?: string;
protocol?: Host["protocol"];
group?: string;
}
export const toVaultImportExistingHostRef = (
host: Pick<Host, "id" | "label" | "hostname" | "port" | "username" | "protocol" | "group">,
): VaultImportExistingHostRef => ({
id: host.id,
label: host.label,
hostname: host.hostname,
port: host.port,
username: host.username,
protocol: host.protocol,
group: host.group,
});
export type VaultImportFormat =
| "putty"
| "mobaxterm"
@@ -254,9 +278,32 @@ export function applyVaultImportDestination(
return remapped;
};
// Collapsing two group copies must not leave a chain pointed at the dropped id.
// Ids that are not part of this import (an existing vault hop) stay as they are.
const remappedHosts = hosts.map((host) => {
const currentIds = host.hostChain?.hostIds;
if (!currentIds?.length) return host;
const hostIds: string[] = [];
for (const hostId of currentIds) {
const mapped = remapHostId(hostId);
const nextId = mapped ?? (originalById.has(hostId) ? undefined : hostId);
if (!nextId || nextId === host.id || hostIds.includes(nextId)) continue;
hostIds.push(nextId);
}
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
return host;
}
if (hostIds.length === 0) {
const next = { ...host };
delete next.hostChain;
return next;
}
return { ...host, hostChain: { hostIds } };
});
return {
...result,
hosts,
hosts: remappedHosts,
groups: [group],
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
// Keep remapped candidates for alias conflict checks (same as CSV same-group
@@ -421,8 +468,176 @@ const parseTarget = (
return null;
};
const CSV_JUMP_PORT = 22;
const importFromCsv = (text: string): VaultImportResult => {
const csvJumpPort = (port: number | undefined): number => (
port !== undefined && Number.isInteger(port) && port >= 1 && port <= 65535 ? port : CSV_JUMP_PORT
);
type CsvJumpCandidate = {
id: string;
label: string;
hostname: string;
port?: number;
username?: string;
protocol?: Host["protocol"];
group?: string;
};
const csvJumpEndpointMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
if (!canServeAsSshJumpHost(candidate)) return false;
if (candidate.hostname.trim().toLowerCase() !== hop.hostname.toLowerCase()) return false;
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
return true;
};
const csvJumpLabelMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
if (!canServeAsSshJumpHost(candidate)) return false;
const label = candidate.label.trim().toLowerCase();
if (!label || label !== hop.hostname.toLowerCase()) return false;
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
return true;
};
const pickCsvJumpCandidate = <T extends { group?: string }>(matches: readonly T[], group: string | undefined): T => (
matches.find((candidate) => (candidate.group ?? "") === (group ?? "")) ?? matches[0]
);
const attachCsvProxyJumpChains = (
hosts: Host[],
proxyJumpByHost: ReadonlyMap<Host, { value: string; row: number }>,
issues: VaultImportIssue[],
existingHosts: readonly VaultImportExistingHostRef[],
): Host[] => {
const inlineHosts: Host[] = [];
const matchable: CsvJumpCandidate[] = [...hosts];
const resolveHop = (hop: ParsedJumpHost, group: string | undefined): string | null => {
const endpointMatches = [
...matchable.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
...existingHosts.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
];
if (endpointMatches.length > 0) return pickCsvJumpCandidate(endpointMatches, group).id;
const labelMatches = [
...matchable.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
...existingHosts.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
];
if (labelMatches.length > 0) return pickCsvJumpCandidate(labelMatches, group).id;
const inlineKey = `${hop.hostname.toLowerCase()}|${csvJumpPort(hop.port)}|${(hop.username ?? "").toLowerCase()}`;
const existingInline = inlineHosts.find((host) => (
`${host.hostname.toLowerCase()}|${csvJumpPort(host.port)}|${(host.username ?? "").toLowerCase()}` === inlineKey
));
if (existingInline) return existingInline.id;
if (!looksLikeHostnameToken(hop.hostname)) return null;
try {
const inlineHost = createHost({
label: hop.hostname,
hostname: hop.hostname,
username: hop.username,
port: hop.port,
protocol: "ssh",
});
inlineHosts.push(inlineHost);
matchable.push(inlineHost);
return inlineHost.id;
} catch {
return null;
}
};
for (const host of hosts) {
const entry = proxyJumpByHost.get(host);
if (!entry) continue;
const specs = entry.value
.split(",")
.map((part) => part.trim())
.filter((part) => part && part.toLowerCase() !== "none");
if (specs.length === 0) continue;
const resolvedIds: string[] = [];
const unresolved: string[] = [];
const inlineCountBefore = inlineHosts.length;
let chainRejected = false;
for (const spec of specs) {
const jump = parseJumpHostSpec(spec);
if (!jump) {
issues.push({
level: "warning",
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
});
chainRejected = true;
continue;
}
const inlineCount = inlineHosts.length;
const resolvedId = resolveHop(jump, host.group);
if (!resolvedId) {
issues.push({
level: "warning",
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
});
chainRejected = true;
continue;
}
if (inlineHosts.length > inlineCount) unresolved.push(jump.hostname);
if (!resolvedIds.includes(resolvedId)) resolvedIds.push(resolvedId);
}
if (chainRejected || resolvedIds.includes(host.id)) {
if (inlineHosts.length > inlineCountBefore) {
const droppedIds = new Set(inlineHosts.splice(inlineCountBefore).map((item) => item.id));
for (let index = matchable.length - 1; index >= 0; index -= 1) {
if (droppedIds.has(matchable[index].id)) matchable.splice(index, 1);
}
}
if (resolvedIds.includes(host.id)) {
issues.push({
level: "warning",
message: `CSV: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
});
}
continue;
}
if (resolvedIds.length > 0) host.hostChain = { hostIds: resolvedIds };
if (unresolved.length > 0) {
issues.push({
level: "warning",
message: `CSV: created inline jump host(s) for "${host.label}": ${unresolved.join(", ")}`,
});
}
}
const allHosts = [...hosts, ...inlineHosts];
const participatesInCycle = (hostId: string, visited: Set<string>): boolean => {
if (visited.has(hostId)) return true;
visited.add(hostId);
const current = allHosts.find((item) => item.id === hostId);
for (const chainId of current?.hostChain?.hostIds ?? []) {
if (!allHosts.some((item) => item.id === chainId)) continue;
if (participatesInCycle(chainId, visited)) return true;
}
visited.delete(hostId);
return false;
};
const cyclicHosts = allHosts.filter((host) => (
Boolean(host.hostChain?.hostIds?.length) && participatesInCycle(host.id, new Set())
));
for (const host of cyclicHosts) {
issues.push({
level: "warning",
message: `CSV: detected circular dependency in jump chain for "${host.label}", removing chain.`,
});
delete host.hostChain;
}
return allHosts;
};
const importFromCsv = (
text: string,
existingHosts: readonly VaultImportExistingHostRef[] = [],
): VaultImportResult => {
const issues: VaultImportIssue[] = [];
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
if (rows.length === 0) {
@@ -448,6 +663,7 @@ const importFromCsv = (text: string): VaultImportResult => {
const keyPathIdx = findExactHeaderIndex(header, ["keypath", "key path", "identityfile", "identity file"]);
const explicitPassphraseIdx = findExactHeaderIndex(header, ["passphrase", "keypassphrase", "key passphrase"]);
const proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
const proxyJumpIdx = findExactHeaderIndex(header, ["proxyjump"]);
const localShellIdx = findExactHeaderIndex(header, ["localshell"]);
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
@@ -482,6 +698,7 @@ const importFromCsv = (text: string): VaultImportResult => {
}
const parsedHosts: Host[] = [];
const proxyJumpByHost = new Map<Host, { value: string; row: number }>();
const keyPassphraseCandidates: Array<{
hostKey: string;
keyPathKey: string;
@@ -549,6 +766,8 @@ const importFromCsv = (text: string): VaultImportResult => {
message: `CSV row ${i + 2}: LocalShell was ignored because it is not a valid encoded local-shell value.`,
});
}
const proxyJumpRaw = (proxyJumpIdx >= 0 ? row[proxyJumpIdx] : undefined)?.trim();
const proxyJump = proxyJumpRaw ? decodeCsvProxyJump(proxyJumpRaw).trim() : "";
if (decodedPassphrase && isEncryptedCredentialPlaceholder(decodedPassphrase)) {
issues.push({
@@ -586,6 +805,9 @@ const importFromCsv = (text: string): VaultImportResult => {
if (localShellSpec.startDir) host.localStartDir = localShellSpec.startDir;
if (localShellSpec.os) host.os = localShellSpec.os;
}
if (proxyJump && proxyJump.toLowerCase() !== "none") {
proxyJumpByHost.set(host, { value: proxyJump, row: i + 2 });
}
parsedHosts.push(host);
if (keyPath && passphrase) {
const keyPathKey = normalizeKeyPathKey(keyPath);
@@ -612,7 +834,8 @@ const importFromCsv = (text: string): VaultImportResult => {
// the shell identity is part of buildVaultHostMergeKey; otherwise importing a
// backup with CMD and PowerShell in the same group silently keeps only the
// first shell and flags the rest as duplicates.
const { hosts, duplicates } = dedupeHosts(parsedHosts);
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
const hosts = attachCsvProxyJumpChains(dedupedHosts, proxyJumpByHost, issues, existingHosts);
const keyPassphrases = hosts.flatMap((host) => {
const selectedKeyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
if (!selectedKeyPath) return [];
@@ -1409,15 +1632,22 @@ const importFromMobaXterm = (
};
};
export interface VaultImportTextOptions {
fileName?: string;
masterPassword?: string;
/** Hosts already in the vault. CSV ProxyJump hops bind to these by endpoint. */
existingHosts?: readonly VaultImportExistingHostRef[];
}
export const importVaultHostsFromText = (
format: VaultImportFormat,
text: string,
options?: { fileName?: string; masterPassword?: string },
options?: VaultImportTextOptions,
): VaultImportResult => {
const input = text ?? "";
switch (format) {
case "csv":
return importFromCsv(input);
return importFromCsv(input, options?.existingHosts);
case "putty":
return importFromPuttyReg(input);
case "ssh_config":
@@ -1508,15 +1738,40 @@ export function applyVaultHostImport(
addedHosts: Host[];
} {
const skipDuplicates = options?.skipDuplicates !== false;
const existingKeys = new Set(existingHosts.map(buildVaultHostMergeKey));
const existingByKey = new Map(existingHosts.map((host) => [buildVaultHostMergeKey(host), host]));
const droppedIdToExistingId = new Map<string, string>();
let newHosts = importResult.hosts;
let skippedExistingCount = 0;
if (skipDuplicates) {
newHosts = importResult.hosts.filter((host) => {
const duplicate = existingKeys.has(buildVaultHostMergeKey(host));
if (duplicate) skippedExistingCount++;
return !duplicate;
const existing = existingByKey.get(buildVaultHostMergeKey(host));
if (!existing) return true;
skippedExistingCount++;
if (existing.id !== host.id) droppedIdToExistingId.set(host.id, existing.id);
return false;
});
}
if (droppedIdToExistingId.size > 0) {
newHosts = newHosts.map((host) => {
const currentIds = host.hostChain?.hostIds;
if (!currentIds?.length) return host;
const hostIds: string[] = [];
for (const hostId of currentIds) {
const mapped = droppedIdToExistingId.get(hostId) ?? hostId;
if (mapped === host.id || hostIds.includes(mapped)) continue;
hostIds.push(mapped);
}
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
return host;
}
if (hostIds.length === 0) {
const next = { ...host };
delete next.hostChain;
return next;
}
return { ...host, hostChain: { hostIds } };
});
}
@@ -2,6 +2,7 @@ const FORMULA_PREFIX = /^[=+\-@\t\r]/u;
const KEY_PATH_MARKER = "__netcatty_csv_keypath_v1__:";
const PASSPHRASE_MARKER = "__netcatty_csv_passphrase_v1__:";
const PROXY_MARKER = "__netcatty_csv_proxy_v1__:";
const PROXY_JUMP_MARKER = "__netcatty_csv_proxyjump_v1__:";
const LOCAL_SHELL_MARKER = "__netcatty_csv_localshell_v1__:";
const encodeMarkedField = (value: string, marker: string): string => (
@@ -43,6 +44,14 @@ export const decodeCsvProxy = (value: string): string => (
decodeMarkedField(value, PROXY_MARKER)
);
export const encodeCsvProxyJump = (value: string): string => (
encodeMarkedField(value, PROXY_JUMP_MARKER)
);
export const decodeCsvProxyJump = (value: string): string => (
decodeMarkedField(value, PROXY_JUMP_MARKER)
);
export interface CsvLocalShellSpec {
shell?: string;
shellArgs?: string[];
+226 -1
View File
@@ -3,8 +3,9 @@ import test from "node:test";
import { publishActiveGlobalDefaultProxyProfileId } from "../globalDefaultProxy.ts";
import { applyGroupDefaults } from "../groupConfig.ts";
import { importVaultHostsFromText } from "../vaultImport.ts";
import { applyVaultHostImport, applyVaultImportDestination, importVaultHostsFromText } from "../vaultImport.ts";
import { exportHostsToCsvWithStats, getVaultCsvTemplate } from "./csvExport.ts";
import { parseCsv } from "./csvUtils.ts";
import type { Host } from "../models.ts";
const hostDefaults = { username: "root", tags: [], os: "linux" as const };
@@ -606,6 +607,230 @@ test("CSV round-trips local-shell vault host settings", () => {
assert.equal(roundTrippedDefault.localStartDir, undefined);
});
const proxyJumpCell = (csv: string, hostname: string): string => {
const rows = parseCsv(csv.replace(/^\uFEFF/, "")).filter((row) => row.some((cell) => cell.trim() !== ""));
const header = rows[0] ?? [];
const jumpIndex = header.indexOf("ProxyJump");
const hostIndex = header.findIndex((cell) => cell.toLowerCase().includes("hostname"));
const row = rows.slice(1).find((cells) => cells[hostIndex] === hostname);
return jumpIndex >= 0 ? (row?.[jumpIndex] ?? "") : "";
};
const chainHost = (id: string, label: string, hostname: string, username: string, port = 22): Host => ({
...hostDefaults,
id,
label,
hostname,
username,
port,
});
test("CSV export writes one-hop and two-hop ProxyJump chains", () => {
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
const v6 = chainHost("jump-v6", "IPv6 hop", "2001:db8::1", "carol", 2222);
const oneHop = chainHost("app-1", "One hop", "one.example.com", "root");
oneHop.hostChain = { hostIds: [bastion.id] };
const twoHop = chainHost("app-2", "Two hop", "two.example.com", "root");
twoHop.hostChain = { hostIds: [bastion.id, second.id] };
const viaV6 = chainHost("app-v6", "Via IPv6", "v6.example.com", "root");
viaV6.hostChain = { hostIds: [v6.id] };
const direct = chainHost("app-3", "Direct", "direct.example.com", "root");
const { csv } = exportHostsToCsvWithStats([bastion, second, v6, oneHop, twoHop, viaV6, direct]);
assert.equal(proxyJumpCell(csv, bastion.hostname), "");
assert.equal(proxyJumpCell(csv, oneHop.hostname), "alice@bastion.example.com");
assert.equal(proxyJumpCell(csv, twoHop.hostname), "alice@bastion.example.com,bob@jump.example.com:2222");
assert.equal(proxyJumpCell(csv, viaV6.hostname), "carol@[2001:db8::1]:2222");
assert.equal(proxyJumpCell(csv, direct.hostname), "");
assert.match(getVaultCsvTemplate().split(/\r?\n/, 1)[0] ?? "", /(^|,)ProxyJump(,|$)/);
});
test("CSV round-trips a jump host chain onto the imported hosts", () => {
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
const target = chainHost("app", "App", "app.example.com", "root");
target.group = "Prod";
target.hostChain = { hostIds: [bastion.id, second.id] };
const { csv } = exportHostsToCsvWithStats([bastion, second, target]);
const imported = importVaultHostsFromText("csv", csv);
const byHostname = new Map(imported.hosts.map((host) => [host.hostname, host]));
const importedTarget = byHostname.get(target.hostname);
const importedBastion = byHostname.get(bastion.hostname);
const importedSecond = byHostname.get(second.hostname);
assert.equal(imported.issues.length, 0);
assert.deepEqual(importedTarget?.hostChain?.hostIds, [importedBastion?.id, importedSecond?.id]);
assert.equal(importedBastion?.hostChain, undefined);
assert.equal(importedSecond?.port, 2222);
assert.equal(importedSecond?.username, "bob");
});
test("CSV import creates an inline jump host when the hop is not in the file", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"app.example.com,root,alice@bastion.example.com:2222",
].join("\n"));
const target = imported.hosts.find((host) => host.hostname === "app.example.com");
const jump = imported.hosts.find((host) => host.hostname === "bastion.example.com");
assert.equal(imported.hosts.length, 2);
assert.equal(jump?.username, "alice");
assert.equal(jump?.port, 2222);
assert.equal(jump?.protocol, "ssh");
assert.deepEqual(target?.hostChain?.hostIds, jump ? [jump.id] : []);
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /inline jump host/i.test(issue.message)));
});
test("CSV import binds a missing hop to an existing vault host by endpoint", () => {
const existing = chainHost("vault-jump", "Old bastion", "bastion.example.com", "alice", 2222);
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"app.example.com,root,alice@bastion.example.com:2222",
].join("\n"), { existingHosts: [existing] });
assert.equal(imported.hosts.length, 1);
assert.equal(imported.hosts[0]?.hostname, "app.example.com");
assert.deepEqual(imported.hosts[0]?.hostChain, { hostIds: [existing.id] });
assert.deepEqual(imported.issues, []);
const moved = applyVaultImportDestination(imported, { mode: "group", group: "Moved" });
assert.deepEqual(moved.hosts[0]?.hostChain, { hostIds: [existing.id] });
});
test("CSV import keeps an old file without a ProxyJump column unchanged", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,Password",
"legacy.example.com,root,secret",
].join("\n"));
assert.equal(imported.hosts.length, 1);
assert.equal(imported.hosts[0]?.hostname, "legacy.example.com");
assert.equal(imported.hosts[0]?.username, "root");
assert.equal(imported.hosts[0]?.password, "secret");
assert.equal(imported.hosts[0]?.hostChain, undefined);
assert.deepEqual(imported.issues, []);
});
test("CSV import retargets a chain when the jump host is already in the vault", () => {
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
bastion.group = "Prod";
const target = chainHost("app", "App", "app.example.com", "root");
target.group = "Prod";
target.hostChain = { hostIds: [bastion.id] };
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
existing.group = "Prod";
const { csv } = exportHostsToCsvWithStats([bastion, target]);
const imported = importVaultHostsFromText("csv", csv, { existingHosts: [existing] });
const merged = applyVaultHostImport([existing], ["Prod"], imported);
assert.equal(merged.addedHosts.length, 1);
assert.equal(merged.addedHosts[0]?.hostname, "app.example.com");
assert.deepEqual(merged.addedHosts[0]?.hostChain, { hostIds: [existing.id] });
assert.equal(merged.hosts.some((host) => host.id !== existing.id && host.hostname === bastion.hostname), false);
});
test("CSV export materializes a jump chain inherited from group defaults", () => {
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
const target = chainHost("app", "App", "app.example.com", "root");
target.group = "Corp";
const { csv } = exportHostsToCsvWithStats([bastion, target], {
groupConfigs: [{ path: "Corp", hostChain: { hostIds: [bastion.id] } }],
});
assert.equal(proxyJumpCell(csv, target.hostname), "alice@bastion.example.com");
});
test("CSV import warns and drops a circular ProxyJump chain", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"a.example.com,root,b.example.com",
"b.example.com,root,a.example.com",
].join("\n"));
assert.equal(imported.hosts.length, 2);
assert.equal(imported.hosts.every((host) => host.hostChain === undefined), true);
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
});
test("CSV import drops inline hops created only for a rejected self-referential ProxyJump", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"ok.example.com,root,kept-hop.example.com",
"app.example.com,root,\"new-hop.example.com,app.example.com\"",
].join("\n"));
const hostnames = imported.hosts.map((host) => host.hostname).sort();
assert.deepEqual(hostnames, ["app.example.com", "kept-hop.example.com", "ok.example.com"]);
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
const ok = imported.hosts.find((host) => host.hostname === "ok.example.com");
const kept = imported.hosts.find((host) => host.hostname === "kept-hop.example.com");
assert.equal(app?.hostChain, undefined);
assert.deepEqual(ok?.hostChain?.hostIds, kept ? [kept.id] : []);
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
});
test("CSV import prefers a same-group existing hop over a cross-group imported match", () => {
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
existing.group = "Prod";
const imported = importVaultHostsFromText("csv", [
"Groups,Hostname,Username,ProxyJump",
"Other,bastion.example.com,alice,",
"Prod,app.example.com,root,alice@bastion.example.com",
].join("\n"), { existingHosts: [existing] });
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
const importedBastion = imported.hosts.find((host) => host.hostname === "bastion.example.com");
assert.equal(importedBastion?.group, "Other");
assert.notEqual(importedBastion?.id, existing.id);
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
});
test("CSV import prefers a same-group existing label hop over a cross-group imported label", () => {
const existing = chainHost("vault-jump", "Bastion", "vault-bastion.internal", "alice");
existing.group = "Prod";
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Username,ProxyJump",
"Other,Bastion,other-bastion.internal,alice,",
"Prod,App,app.example.com,root,alice@Bastion",
].join("\n"), { existingHosts: [existing] });
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
const importedBastion = imported.hosts.find((host) => host.hostname === "other-bastion.internal");
assert.equal(importedBastion?.group, "Other");
assert.equal(importedBastion?.label, "Bastion");
assert.notEqual(importedBastion?.id, existing.id);
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
});
test("CSV import warns and ignores a ProxyJump value that is not a jump host", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"app.example.com,root,not a hop",
].join("\n"));
assert.equal(imported.hosts.length, 1);
assert.equal(imported.hosts[0]?.hostChain, undefined);
assert.ok(imported.issues.some((issue) => issue.level === "warning" && issue.message.includes("ProxyJump")));
});
test("CSV import rejects a ProxyJump chain that contains a malformed hop", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"app.example.com,root,\"bastion.example.com,not a hop\"",
].join("\n"));
assert.deepEqual(imported.hosts.map((host) => host.hostname), ["app.example.com"]);
assert.equal(imported.hosts[0]?.hostChain, undefined);
assert.ok(imported.issues.some((issue) => (
issue.level === "warning" && issue.message.includes("not a hop")
)));
});
test("CSV import warns and ignores a malformed LocalShell value", () => {
const csv = [
"Groups,Label,Tags,Notes,Hostname/IP,Protocol,Port,Username,Password,KeyPath,Passphrase,Proxy,LocalShell",
+46 -4
View File
@@ -2,7 +2,8 @@ import type { GroupConfig, Host, Identity, ProxyProfile } from '../models';
import { applyGroupDefaults, resolveGroupDefaults } from '../groupConfig';
import { isPluginHostProtocol } from '../pluginConnection';
import { hasUnreadableProxyCredential, materializeHostProxyProfile, resolveProxyConfigAuth } from '../proxyProfiles';
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
import { canServeAsSshJumpHost } from '../terminalProtocol';
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy, encodeCsvProxyJump } from './csvCredentialFields';
import { CSV_DIRECT_PROXY_VALUE, formatCsvProxy } from './csvProxy';
const UTF8_BOM = "\uFEFF";
@@ -36,7 +37,7 @@ export const getVaultCsvTemplate = (
opts: VaultCsvTemplateOptions = {},
): string => {
const includeExampleRows = opts.includeExampleRows !== false;
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
const rows: string[][] = [header];
if (includeExampleRows) {
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", "", "~/.ssh/id_ed25519", "", "socks5://127.0.0.1:1080"]);
@@ -77,8 +78,44 @@ const getLocalShellSpec = (host: Host): CsvLocalShellSpec | null => (
: null
);
const UNSAFE_PROXY_JUMP_USERNAME = /[\s,#@]/;
const UNSAFE_PROXY_JUMP_HOSTNAME = /[\s,#@]/;
const DEFAULT_PROXY_JUMP_PORT = 22;
// OpenSSH ProxyJump: [user@]host[:port], repeated. Port 22 is omitted.
// IPv6 is bracketed so the port separator stays unambiguous.
const formatCsvProxyJumpHop = (host: Host): string | null => {
if (!canServeAsSshJumpHost(host)) return null;
const username = host.username?.trim() ?? "";
const hostname = host.hostname.trim().replace(/^\[(.*)\]$/u, "$1");
if (!hostname) return null;
if (username && UNSAFE_PROXY_JUMP_USERNAME.test(username)) return null;
if (UNSAFE_PROXY_JUMP_HOSTNAME.test(hostname)) return null;
const port = host.port ?? DEFAULT_PROXY_JUMP_PORT;
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
const hostPart = hostname.includes(":") ? `[${hostname}]` : hostname;
const withUser = username ? `${username}@${hostPart}` : hostPart;
return port === DEFAULT_PROXY_JUMP_PORT ? withUser : `${withUser}:${port}`;
};
// An empty cell means "no chain". A chain is omitted entirely when any hop
// cannot be represented, so a shortened list is not written back as the truth.
const formatCsvProxyJump = (host: Host, allHosts: readonly Host[]): string => {
const ids = host.hostChain?.hostIds ?? [];
if (ids.length === 0) return "";
const byId = new Map(allHosts.map((item) => [item.id, item]));
const parts: string[] = [];
for (const id of ids) {
const hop = byId.get(id);
const formatted = hop ? formatCsvProxyJumpHop(hop) : null;
if (!formatted) return "";
parts.push(formatted);
}
return parts.join(",");
};
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
const rows: string[][] = [header];
let unreadableProxyCredentialCount = 0;
@@ -161,6 +198,9 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
}
}
const localShellSpec = getLocalShellSpec(host);
// Group defaults are already on effectiveHost, same as Proxy, so a chain
// inherited from the group survives a file that has no group-config section.
const proxyJumpValue = formatCsvProxyJump(effectiveHost, exportableHosts);
rows.push([
host.group ?? "",
@@ -175,6 +215,7 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
encodeCsvKeyPath(keyPath),
encodeCsvPassphrase(passphrase),
encodeCsvProxy(proxyValue),
encodeCsvProxyJump(proxyJumpValue),
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
]);
}
@@ -183,9 +224,10 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
const keyPathColIdx = header.indexOf("KeyPath");
const passphraseColIdx = header.indexOf("Passphrase");
const proxyColIdx = header.indexOf("Proxy");
const proxyJumpColIdx = header.indexOf("ProxyJump");
const csv = rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(
c,
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx),
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx || i === proxyJumpColIdx),
)).join(",")).join("\r\n") + "\r\n";
return { csv, unreadableProxyCredentialCount };
};
+5 -1
View File
@@ -51,6 +51,7 @@ import {
importVaultHostsFromText,
mergeVaultImportIssues,
resolveVaultImportKeyPassphraseConflicts,
toVaultImportExistingHostRef,
VAULT_IMPORT_FORMATS,
type VaultImportFormat,
} from '../../domain/vaultImport';
@@ -946,7 +947,10 @@ export async function handleVaultAgentOp(
? params.fileName.trim()
: undefined;
const importResult = importVaultHostsFromText(resolvedFormat, text, { fileName });
const importResult = importVaultHostsFromText(resolvedFormat, text, {
fileName,
existingHosts: deps.getHosts().map(toVaultImportExistingHostRef),
});
const previewHosts = importResult.hosts.map((host) => sanitizeHostForAgent(host));
const merged = applyVaultHostImport(
deps.getHosts(),
@@ -3,6 +3,7 @@ import {
buildVaultHostMergeKey,
importVaultHostsFromText,
mergeVaultImportIssues,
type VaultImportExistingHostRef,
type VaultImportFormat,
type VaultImportIssue,
type VaultImportResult,
@@ -24,6 +25,7 @@ interface ImportVaultHostFilesOptions {
relativePaths?: string[];
encoding?: VaultImportFileEncoding;
masterPassword?: string;
existingHosts?: readonly VaultImportExistingHostRef[];
onProgress?: (progress: VaultImportBatchProgress) => void;
}
@@ -78,6 +80,7 @@ export async function importVaultHostFiles({
relativePaths,
encoding,
masterPassword,
existingHosts,
onProgress,
}: ImportVaultHostFilesOptions): Promise<VaultImportResult> {
const sourceFiles = files.map((file, index) => ({
@@ -104,6 +107,7 @@ export async function importVaultHostFiles({
const result = importVaultHostsFromText(format, text, {
fileName: file.name,
masterPassword,
existingHosts,
});
const group = format === "securecrt"
? secureCrtGroupFromFile(file, relativePath)
@@ -1,4 +1,5 @@
import type {
VaultImportExistingHostRef,
VaultImportFormat,
VaultImportResult,
} from "../../domain/vaultImport";
@@ -22,6 +23,7 @@ export type VaultImportWorkerRequest = {
relativePaths: string[];
encoding: VaultImportFileEncoding | undefined;
masterPassword?: string;
existingHosts?: VaultImportExistingHostRef[];
};
export type VaultImportWorkerResponse =
@@ -44,6 +46,7 @@ interface ImportVaultHostsInWorkerOptions {
files: File[];
encoding?: VaultImportFileEncoding;
masterPassword?: string;
existingHosts?: readonly VaultImportExistingHostRef[];
signal?: AbortSignal;
createWorker?: () => VaultImportWorkerLike;
onProgress?: (progress: VaultImportWorkerProgress) => void;
@@ -60,6 +63,7 @@ export function importVaultHostsInWorker({
files,
encoding,
masterPassword,
existingHosts,
signal,
createWorker = createVaultImportWorker,
onProgress,
@@ -114,6 +118,7 @@ export function importVaultHostsInWorker({
relativePaths: files.map((file) => file.webkitRelativePath),
encoding,
...(masterPassword ? { masterPassword } : {}),
...(existingHosts && existingHosts.length > 0 ? { existingHosts: [...existingHosts] } : {}),
});
});
}
@@ -23,6 +23,7 @@ workerScope.addEventListener("message", async (event: MessageEvent<VaultImportWo
relativePaths: event.data.relativePaths,
encoding: event.data.encoding,
masterPassword: event.data.masterPassword,
existingHosts: event.data.existingHosts,
onProgress: ({ completedFiles, totalFiles, fileName }) => {
post({
type: "progress",