Add --passfile so credentials stay off the command line

-scp/-dcp put passwords on argv, where any local user can read them with
ps. This was not theoretical: a running migration was observed exposing
both an AS/400 and a Postgres password in plaintext.

-sc/-dc aliases already avoided that, but only read ~/.jrunnerpass, and
the pipekit service account is created with --no-create-home, so it had
no way to use them. --passfile points at an arbitrary path.

Also warn when the passfile is world-readable, and report the resolved
path in errors instead of a hardcoded ~/.jrunnerpass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-08-07 08:59:53 -04:00
parent 2ced7810d9
commit e3f624f4d8

View File

@ -4,6 +4,7 @@ import java.util.*;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path ; import java.nio.file.Path ;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.nio.file.attribute.PosixFilePermission;
import java.time.*; import java.time.*;
import java.io.IOException; import java.io.IOException;
import com.microsoft.sqlserver.jdbc.SQLServerBulkCopy; import com.microsoft.sqlserver.jdbc.SQLServerBulkCopy;
@ -40,6 +41,7 @@ public class jrunner {
String nl = "\n"; String nl = "\n";
Boolean queryMode = false; Boolean queryMode = false;
String outputFormat = "csv"; String outputFormat = "csv";
String passFilePath = "";
String msg = ""; String msg = "";
Connection scon = null; Connection scon = null;
Connection dcon = null; Connection dcon = null;
@ -67,9 +69,13 @@ public class jrunner {
msg = msg + nl + "-c clear target table"; msg = msg + nl + "-c clear target table";
msg = msg + nl + "-b bulk copy into destination (SQL Server dest only)"; msg = msg + nl + "-b bulk copy into destination (SQL Server dest only)";
msg = msg + nl + "-f output format (csv, tsv, table, json) - default: csv"; msg = msg + nl + "-f output format (csv, tsv, table, json) - default: csv";
msg = msg + nl + "--passfile path to the connection alias file - default: ~/.jrunnerpass";
msg = msg + nl + "--help info"; msg = msg + nl + "--help info";
msg = msg + nl + ""; msg = msg + nl + "";
msg = msg + nl + "~/.jrunnerpass format:"; msg = msg + nl + "Prefer -sc/-dc aliases over -scp/-dcp: a password given on the";
msg = msg + nl + "command line is visible to any local user via 'ps'.";
msg = msg + nl + "";
msg = msg + nl + "passfile format:";
msg = msg + nl + " [alias]"; msg = msg + nl + " [alias]";
msg = msg + nl + " url=jdbc:..."; msg = msg + nl + " url=jdbc:...";
msg = msg + nl + " user=username"; msg = msg + nl + " user=username";
@ -140,6 +146,10 @@ public class jrunner {
case "-f": case "-f":
outputFormat = args[i+1].toLowerCase(); outputFormat = args[i+1].toLowerCase();
break; break;
//alias file location (the service user has no home directory)
case "--passfile":
passFilePath = args[i+1];
break;
case "-v": case "-v":
System.out.println(msg); System.out.println(msg);
return; return;
@ -163,13 +173,16 @@ public class jrunner {
} }
} }
// Resolve connection aliases from ~/.jrunnerpass // Resolve connection aliases from the passfile
if (!scAlias.isEmpty() || !dcAlias.isEmpty()) { if (!scAlias.isEmpty() || !dcAlias.isEmpty()) {
Map<String, String[]> connections = loadPassFile(); Path resolvedPassFile = passFilePath.isEmpty()
? Paths.get(System.getProperty("user.home"), ".jrunnerpass")
: Paths.get(passFilePath);
Map<String, String[]> connections = loadPassFile(resolvedPassFile);
if (!scAlias.isEmpty()) { if (!scAlias.isEmpty()) {
String[] sc = connections.get(scAlias); String[] sc = connections.get(scAlias);
if (sc == null) { if (sc == null) {
System.err.println("Error: source alias '" + scAlias + "' not found in ~/.jrunnerpass"); System.err.println("Error: source alias '" + scAlias + "' not found in " + resolvedPassFile);
System.exit(1); System.exit(1);
} }
if (scu.isEmpty()) scu = sc[0]; if (scu.isEmpty()) scu = sc[0];
@ -179,7 +192,7 @@ public class jrunner {
if (!dcAlias.isEmpty()) { if (!dcAlias.isEmpty()) {
String[] dc = connections.get(dcAlias); String[] dc = connections.get(dcAlias);
if (dc == null) { if (dc == null) {
System.err.println("Error: destination alias '" + dcAlias + "' not found in ~/.jrunnerpass"); System.err.println("Error: destination alias '" + dcAlias + "' not found in " + resolvedPassFile);
System.exit(1); System.exit(1);
} }
if (dcu.isEmpty()) dcu = dc[0]; if (dcu.isEmpty()) dcu = dc[0];
@ -758,19 +771,22 @@ public class jrunner {
return value.replaceAll("\t", " ").replaceAll("\n", " ").replaceAll("\r", " "); return value.replaceAll("\t", " ").replaceAll("\n", " ").replaceAll("\r", " ");
} }
// Loads ~/.jrunnerpass and returns a map of alias -> {url, user, pass} // Loads a passfile and returns a map of alias -> {url, user, pass}
// Format: // Format:
// [alias] // [alias]
// url=jdbc:... // url=jdbc:...
// user=username // user=username
// pass=password // pass=password
private static Map<String, String[]> loadPassFile() { //
// Defaults to ~/.jrunnerpass; --passfile overrides it, which is what lets a
// service account with no home directory keep its passwords off argv.
private static Map<String, String[]> loadPassFile(Path passFile) {
Map<String, String[]> connections = new LinkedHashMap<>(); Map<String, String[]> connections = new LinkedHashMap<>();
Path passFile = Paths.get(System.getProperty("user.home"), ".jrunnerpass");
if (!Files.exists(passFile)) { if (!Files.exists(passFile)) {
System.err.println("Error: ~/.jrunnerpass not found"); System.err.println("Error: passfile not found: " + passFile);
System.exit(1); System.exit(1);
} }
warnIfGroupOrWorldReadable(passFile);
try { try {
String currentAlias = null; String currentAlias = null;
String url = "", user = "", pass = ""; String url = "", user = "", pass = "";
@ -795,9 +811,23 @@ public class jrunner {
connections.put(currentAlias, new String[]{url, user, pass}); connections.put(currentAlias, new String[]{url, user, pass});
} }
} catch (IOException e) { } catch (IOException e) {
System.err.println("Error reading ~/.jrunnerpass: " + e.getMessage()); System.err.println("Error reading passfile " + passFile + ": " + e.getMessage());
System.exit(1); System.exit(1);
} }
return connections; return connections;
} }
// The passfile holds plaintext passwords, so a too-permissive mode defeats
// the point of moving them off argv. Warn rather than refuse the file may
// be deliberately group-readable by a service group.
private static void warnIfGroupOrWorldReadable(Path passFile) {
try {
Set<PosixFilePermission> perms = Files.getPosixFilePermissions(passFile);
if (perms.contains(PosixFilePermission.OTHERS_READ)) {
System.err.println("Warning: " + passFile + " is world-readable; chmod 600 it");
}
} catch (Exception e) {
// Non-POSIX filesystem, or permissions unreadable not worth failing over.
}
}
} }