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:
parent
2ced7810d9
commit
e3f624f4d8
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user