From b47179b7d4fc078cebe0ef8cc3ea83d2ec685ee8 Mon Sep 17 00:00:00 2001 From: Ekaterina Dimitrova Date: Wed, 28 Jan 2026 19:42:57 -0500 Subject: [PATCH] Add option to disable cqlsh history We can disable saving of the history either via command-line parameter --disable-history, or by setting disabled = True in the history section of the cqlshrc. Both options will read existing history, and just won't save new commands. Update help and docs for cqlsh history. Add startup info logline whenr history logging is enabled. Add a fix for cqlshrc file path not correctly expanding. Includes the Backport of Allows users to change cqlsh history location using env variable patch by Stefan Miklosovic; reviewed by Brandon Williams for CASSANDRA-17448 patch by Ekaterina Dimitrova; reviewed by Mick Semb Wever for CASSANDRA-XXX Co-authored-by: Alex Ott alex.ott@datastax.com Co-authored-by: Jaroslaw Grabowski jaroslaw.grabowski@datastax.com --- CHANGES.txt | 1 + bin/cqlsh.py | 86 +++++++++++++---- conf/cqlshrc.sample | 10 ++ doc/modules/cassandra/pages/tools/cqlsh.adoc | 98 ++++++++++++-------- pylib/cqlshlib/util.py | 21 +++++ 5 files changed, 159 insertions(+), 57 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 71f0693b68..c8d75b2dd8 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ 4.0.20 + * Add option to disable cqlsh history (CASSANDRA-21180) * Rate limit password changes (CASSANDRA-21202) * Node does not send multiple inflight echos (CASSANDRA-18866) * Obsolete expired SSTables before compaction starts (CASSANDRA-19776) diff --git a/bin/cqlsh.py b/bin/cqlsh.py index 51a46c7982..0a3734eb9c 100755 --- a/bin/cqlsh.py +++ b/bin/cqlsh.py @@ -164,7 +164,7 @@ from cqlshlib.formatting import (DEFAULT_DATE_FORMAT, DEFAULT_NANOTIME_FORMAT, DEFAULT_TIMESTAMP_FORMAT, CqlType, DateTimeFormat, format_by_type, formatter_for) from cqlshlib.tracing import print_trace, print_trace_session -from cqlshlib.util import get_file_encoding_bomsize, trim_if_present +from cqlshlib.util import get_file_encoding_bomsize, trim_if_present, is_file_secure try: from cqlshlib.serverversion import version as build_version except ImportError: @@ -230,27 +230,52 @@ parser.add_argument("--request-timeout", default=DEFAULT_REQUEST_TIMEOUT_SECONDS help='Specify the default request timeout in seconds (default: %default seconds).') parser.add_argument("-t", "--tty", action='store_true', dest='tty', help='Force tty mode (command prompt).') +parser.add_argument('--disable-history', default=False, action='store_true', + help='Disable saving of history (existing history will still be loaded)') cfarguments, args = parser.parse_known_args() # BEGIN history/config definition -HISTORY_DIR = os.path.expanduser(os.path.join('~', '.cassandra')) + + +def mkdirp(path): + """Creates all parent directories up to path parameter or fails when path exists, but it is not a directory.""" + + try: + os.makedirs(path) + except OSError: + if not os.path.isdir(path): + raise + + +def resolve_cql_history_file(): + default_cql_history = os.path.expanduser(os.path.join('~', '.cassandra', 'cqlsh_history')) + if 'CQL_HISTORY' in os.environ: + return os.environ['CQL_HISTORY'] + else: + return default_cql_history + + +HISTORY = resolve_cql_history_file() +HISTORY_DIR = os.path.dirname(HISTORY) + +try: + mkdirp(HISTORY_DIR) +except OSError: + print('\nWarning: Cannot create directory at `%s`. Command history will not be saved. Please check what was the environment property CQL_HISTORY set to.\n' % HISTORY_DIR) + +DEFAULT_CQLSHRC = os.path.expanduser(os.path.join('~', '.cassandra', 'cqlshrc')) if cfarguments.cqlshrc is not None: - CONFIG_FILE = cfarguments.cqlshrc + CONFIG_FILE = os.path.expanduser(cfarguments.cqlshrc) if not os.path.exists(CONFIG_FILE): - print('\nWarning: Specified cqlshrc location `%s` does not exist. Using `%s` instead.\n' % (CONFIG_FILE, HISTORY_DIR)) - CONFIG_FILE = os.path.join(HISTORY_DIR, 'cqlshrc') + print('\nWarning: Specified cqlshrc location `%s` does not exist. Using `%s` instead.\n' % (CONFIG_FILE, DEFAULT_CQLSHRC)) + CONFIG_FILE = DEFAULT_CQLSHRC else: - CONFIG_FILE = os.path.join(HISTORY_DIR, 'cqlshrc') + CONFIG_FILE = DEFAULT_CQLSHRC -HISTORY = os.path.join(HISTORY_DIR, 'cqlsh_history') -if not os.path.exists(HISTORY_DIR): - try: - os.mkdir(HISTORY_DIR) - except OSError: - print('\nWarning: Cannot create directory at `%s`. Command history will not be saved.\n' % HISTORY_DIR) +CQL_DIR = os.path.dirname(CONFIG_FILE) OLD_CONFIG_FILE = os.path.expanduser(os.path.join('~', '.cqlshrc')) if os.path.exists(OLD_CONFIG_FILE): @@ -265,6 +290,8 @@ if os.path.exists(OLD_CONFIG_FILE): OLD_HISTORY = os.path.expanduser(os.path.join('~', '.cqlsh_history')) if os.path.exists(OLD_HISTORY): os.rename(OLD_HISTORY, HISTORY) + + # END history/config definition CQL_ERRORS = ( @@ -441,7 +468,8 @@ class Shell(cmd.Cmd): request_timeout=DEFAULT_REQUEST_TIMEOUT_SECONDS, protocol_version=None, connect_timeout=DEFAULT_CONNECT_TIMEOUT_SECONDS, - is_subshell=False): + is_subshell=False, + disable_history=False): cmd.Cmd.__init__(self, completekey=completekey) self.hostname = hostname self.port = port @@ -517,10 +545,19 @@ class Shell(cmd.Cmd): self.check_build_versions() if tty: + # Inform users about history logging if not disabled + if not disable_history and readline is not None: + print() + print("ATTENTION: All commands will be saved to history file: %s" % HISTORY) + print("This may include sensitive information such as passwords.") + print("To disable history, use --disable-history or set 'disabled = true' in the [history] section of cqlshrc.") + print("See https://cassandra.apache.org/doc/latest/tools/cqlsh.html for more information.") + print() self.reset_prompt() self.maybe_warn_py2() self.report_connection() print('Use HELP for help.') + else: self.show_line_nums = True self.stdin = stdin @@ -843,9 +880,9 @@ class Shell(cmd.Cmd): # start coverage collection if requested, unless in subshell if self.coverage and not self.is_subshell: # check for coveragerc file, write it if missing - if os.path.exists(HISTORY_DIR): - self.coveragerc_path = os.path.join(HISTORY_DIR, '.coveragerc') - covdata_path = os.path.join(HISTORY_DIR, '.coverage') + if os.path.exists(CQL_DIR): + self.coveragerc_path = os.path.join(CQL_DIR, '.coveragerc') + covdata_path = os.path.join(CQL_DIR, '.coverage') if not os.path.isfile(self.coveragerc_path): with open(self.coveragerc_path, 'w') as f: f.writelines(["[run]\n", @@ -2110,6 +2147,13 @@ def read_options(cmdlineargs, environment): rawconfigs = configparser.RawConfigParser() rawconfigs.read(CONFIG_FILE) + username_from_cqlshrc = option_with_default(configs.get, 'authentication', 'username') + password_from_cqlshrc = option_with_default(rawconfigs.get, 'authentication', 'password') + if username_from_cqlshrc or password_from_cqlshrc: + if password_from_cqlshrc and not is_file_secure(os.path.expanduser(CONFIG_FILE)): + print("\nWarning: Password is found in an insecure cqlshrc file. The file is owned or readable by other users on the system.", + end='', file=sys.stderr) + argvalues = argparse.Namespace() argvalues.username = option_with_default(configs.get, 'authentication', 'username') @@ -2150,6 +2194,7 @@ def read_options(cmdlineargs, environment): argvalues.connect_timeout = option_with_default(configs.getint, 'connection', 'timeout', DEFAULT_CONNECT_TIMEOUT_SECONDS) argvalues.request_timeout = option_with_default(configs.getint, 'connection', 'request_timeout', DEFAULT_REQUEST_TIMEOUT_SECONDS) argvalues.execute = None + argvalues.disable_history = option_with_default(configs.getboolean, 'history', 'disabled', False) options, arguments = parser.parse_known_args(cmdlineargs, argvalues) # Make sure some user values read from the command line are in unicode @@ -2234,8 +2279,8 @@ def init_history(): readline.set_completer_delims(delims) -def save_history(): - if readline is not None: +def save_history(history_disabled=False): + if readline is not None and not history_disabled: try: readline.write_history_file(HISTORY) except IOError: @@ -2320,7 +2365,8 @@ def main(options, hostname, port): single_statement=options.execute, request_timeout=options.request_timeout, connect_timeout=options.connect_timeout, - encoding=options.encoding) + encoding=options.encoding, + disable_history=options.disable_history) except KeyboardInterrupt: sys.exit('Connection aborted.') except CQL_ERRORS as e: @@ -2340,7 +2386,7 @@ def main(options, hostname, port): signal.signal(signal.SIGHUP, handle_sighup) shell.cmdloop() - save_history() + save_history(options.disable_history) if shell.batch_mode and shell.statement_error: sys.exit(2) diff --git a/conf/cqlshrc.sample b/conf/cqlshrc.sample index 2c00d4a91f..50479b3cc6 100644 --- a/conf/cqlshrc.sample +++ b/conf/cqlshrc.sample @@ -100,6 +100,16 @@ port = 9042 +[history] +;; Controls whether command history is saved to ~/.cassandra/cqlsh_history +;; When disabled, existing history will still be loaded but new commands +;; will not be saved. This can be useful for security purposes to prevent +;; sensitive commands (e.g., those containing passwords) from being persisted. +;; This can also be controlled via the --disable-history command line option. +; disabled = false + + + ;[ssl] ; certfile = ~/keys/cassandra.cert diff --git a/doc/modules/cassandra/pages/tools/cqlsh.adoc b/doc/modules/cassandra/pages/tools/cqlsh.adoc index 162259a337..7d3eabbf27 100644 --- a/doc/modules/cassandra/pages/tools/cqlsh.adoc +++ b/doc/modules/cassandra/pages/tools/cqlsh.adoc @@ -38,7 +38,7 @@ modules that are central to the performance of `COPY`. == cqlshrc The `cqlshrc` file holds configuration options for `cqlsh`. -By default, the file is locagted the user's home directory at `~/.cassandra/cqlsh`, but a +By default, the file is located the user's home directory at `~/.cassandra/cqlsh`, but a custom location can be specified with the `--cqlshrc` option. Example config values and documentation can be found in the @@ -46,52 +46,76 @@ Example config values and documentation can be found in the You can also view the latest version of the https://github.com/apache/cassandra/blob/trunk/conf/cqlshrc.sample[cqlshrc file online]. +[[cql_history]] +== cql history + +All CQL commands you execute are written to a history file. By default, CQL history will be written to `~/.cassandra/cql_history`. +You can change this default by setting the environment variable `CQL_HISTORY` like `~/some/other/path/to/cqlsh_history` where `cqlsh_history` +is a file. All parent directories to history file will be created if they do not exist. If you do not want to persist history, +you can do so by setting CQL_HISTORY to /dev/null. This can also be controlled via the --disable-history command line option +or from the cqlshrc file. +Disabling history is supported since Cassandra 4.0. + == Command Line Options -Usage: +Usage: `cqlsh.py [options] [host [port]]` -`cqlsh [options] [host [port]]` +CQL Shell for Apache Cassandra Options: +`--version`:: + show program's version number and exit + +`-h` `--help`:: + show this help message and exit `-C` `--color`:: - Force color output + Always use color output `--no-color`:: - Disable color output -`--browser`:: - Specify the browser to use for displaying cqlsh help. This can be one - of the https://docs.python.org/2/library/webbrowser.html[supported - browser names] (e.g. `firefox`) or a browser path followed by `%s` - (e.g. `/usr/bin/google-chrome-stable %s`). + Never use color output +`--browser=BROWSER`:: + The browser to use to display CQL help, where BROWSER can be: + one of the supported browsers in https://docs.python.org/3/library/webbrowser.html. + browser path followed by %s, example: /usr/bin/google-chrome-stable %s `--ssl`:: - Use SSL when connecting to Cassandra -`-u` `--user`:: - Username to authenticate against Cassandra with -`-p` `--password`:: - Password to authenticate against Cassandra with, should be used in - conjunction with `--user` -`-k` `--keyspace`:: - Keyspace to authenticate to, should be used in conjunction with - `--user` -`-f` `--file`:: - Execute commands from the given file, then exit + Use SSL + +`-u USERNAME` `--username=USERNAME`:: + Authenticate as user. +`-p PASSWORD` `--password=PASSWORD`:: + Authenticate using password. +`-k KEYSPACE` `--keyspace=KEYSPACE`:: + Authenticate to the given keyspace. +`-f FILE` `--file=FILE`:: + Execute commands from FILE, then exit `--debug`:: - Print additional debugging information -`--encoding`:: - Specify a non-default encoding for output (defaults to UTF-8) -`--cqlshrc`:: - Specify a non-default location for the `cqlshrc` file -`-e` `--execute`:: - Execute the given statement, then exit -`--connect-timeout`:: - Specify the connection timeout in seconds (defaults to 2s) -`--python /path/to/python`:: - Specify the full path to Python interpreter to override default on - systems with multiple interpreters installed -`--request-timeout`:: - Specify the request timeout in seconds (defaults to 10s) -`-t` `--tty`:: - Force tty mode (command prompt) + Show additional debugging information +`--coverage`:: + Collect coverage data +`--encoding=ENCODING`:: + Specify a non-default encoding for output. (Default: utf-8) +`--cqlshrc=CQLSHRC`:: + Specify an alternative cqlshrc file location. +`--credentials=CREDENTIALS`:: + Specify an alternative credentials file location. +`--cqlversion=CQLVERSION`:: + Specify a particular CQL version, by default the + highest version supported by the server will be used. + Examples: "3.0.3", "3.1.0" +`--protocol-version=PROTOCOL_VERSION`:: + Specify a specific protcol version otherwise the + client will default and downgrade as necessary +`-e EXECUTE` `--execute=EXECUTE`:: + Execute the statement and quit. +`--connect-timeout=CONNECT_TIMEOUT`:: + Specify the connection timeout in seconds (default: 5 seconds). +`--request-timeout=REQUEST_TIMEOUT`:: + Specify the default request timeout in seconds + (default: 10 seconds). +`-t, --tty`:: + Force tty mode (command prompt). +`-v` `--v`:: + Print the current version of cqlsh. == Special Commands diff --git a/pylib/cqlshlib/util.py b/pylib/cqlshlib/util.py index 82a332f85a..6b8454bb65 100644 --- a/pylib/cqlshlib/util.py +++ b/pylib/cqlshlib/util.py @@ -17,6 +17,9 @@ import cProfile import codecs +import errno +import os +import stat import pstats @@ -102,6 +105,24 @@ def list_bifilter(pred, iterable): return yes_s, no_s +def is_file_secure(filename): + try: + st = os.stat(filename) + uid = os.getuid() + except OSError as e: + if e.errno != errno.ENOENT: + raise + # the file doesn't exist, the security of it is irrelevant + return True + except AttributeError as e: + # not-Unix os + return True + + # Skip enforcing the file owner and UID matching for the root user (uid == 0). + # This is to allow "sudo cqlsh" to work with user owned credentials file. + return (uid == 0 or st.st_uid == uid) and stat.S_IMODE(st.st_mode) & (stat.S_IRGRP | stat.S_IROTH) == 0 + + def identity(x): return x