diff --git a/jrunner/src/main/java/jrunner/jrunner.java b/jrunner/src/main/java/jrunner/jrunner.java index e4533a3..ce244b3 100644 --- a/jrunner/src/main/java/jrunner/jrunner.java +++ b/jrunner/src/main/java/jrunner/jrunner.java @@ -4,6 +4,7 @@ import java.util.*; import java.nio.file.Files; import java.nio.file.Path ; import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermission; import java.time.*; import java.io.IOException; import com.microsoft.sqlserver.jdbc.SQLServerBulkCopy; @@ -40,6 +41,7 @@ public class jrunner { String nl = "\n"; Boolean queryMode = false; String outputFormat = "csv"; + String passFilePath = ""; String msg = ""; Connection scon = null; Connection dcon = null; @@ -67,9 +69,13 @@ public class jrunner { msg = msg + nl + "-c clear target table"; 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 + "--passfile path to the connection alias file - default: ~/.jrunnerpass"; msg = msg + nl + "--help info"; 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 + " url=jdbc:..."; msg = msg + nl + " user=username"; @@ -140,6 +146,10 @@ public class jrunner { case "-f": outputFormat = args[i+1].toLowerCase(); break; + //alias file location (the service user has no home directory) + case "--passfile": + passFilePath = args[i+1]; + break; case "-v": System.out.println(msg); return; @@ -163,13 +173,16 @@ public class jrunner { } } - // Resolve connection aliases from ~/.jrunnerpass + // Resolve connection aliases from the passfile if (!scAlias.isEmpty() || !dcAlias.isEmpty()) { - Map connections = loadPassFile(); + Path resolvedPassFile = passFilePath.isEmpty() + ? Paths.get(System.getProperty("user.home"), ".jrunnerpass") + : Paths.get(passFilePath); + Map connections = loadPassFile(resolvedPassFile); if (!scAlias.isEmpty()) { String[] sc = connections.get(scAlias); 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); } if (scu.isEmpty()) scu = sc[0]; @@ -179,7 +192,7 @@ public class jrunner { if (!dcAlias.isEmpty()) { String[] dc = connections.get(dcAlias); 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); } if (dcu.isEmpty()) dcu = dc[0]; @@ -758,19 +771,22 @@ public class jrunner { 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: // [alias] // url=jdbc:... // user=username // pass=password - private static Map 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 loadPassFile(Path passFile) { Map connections = new LinkedHashMap<>(); - Path passFile = Paths.get(System.getProperty("user.home"), ".jrunnerpass"); if (!Files.exists(passFile)) { - System.err.println("Error: ~/.jrunnerpass not found"); + System.err.println("Error: passfile not found: " + passFile); System.exit(1); } + warnIfGroupOrWorldReadable(passFile); try { String currentAlias = null; String url = "", user = "", pass = ""; @@ -795,9 +811,23 @@ public class jrunner { connections.put(currentAlias, new String[]{url, user, pass}); } } catch (IOException e) { - System.err.println("Error reading ~/.jrunnerpass: " + e.getMessage()); + System.err.println("Error reading passfile " + passFile + ": " + e.getMessage()); System.exit(1); } 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 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. + } + } }