def _put(self): value = Input.input(f"Please input a value to share: ") # Safe convert to int or float, then validate expires_in_hours = Input.input( f"Select # of hours before value auto-expires: ", default="1") expires_in_hours = Utils.safe_cast(expires_in_hours, int, expires_in_hours) expires_in_hours = Utils.safe_cast(expires_in_hours, float, expires_in_hours) self._utils.validate( isinstance(expires_in_hours, int) or isinstance(expires_in_hours, float), "You must provide a number of hours for when this secret should expire. No strings accepted." ) self._utils.validate( expires_in_hours <= 48, "You may not specify an expiration time more than 48 hours in the future." ) secret_id = self._ots.put_ots(value, expires_in_hours) self._out.print( f"\n\nTo share this secret, recipients will need the following") self._out.print(f"\n[[Secret Id]] -> {secret_id}") self._out.success( f"\n\nValue successfully stored, it will expire in {expires_in_hours} hours, or when retrieved." )
def _fill_repl_conf_variables(self, repl_conf: Dict) -> Dict: repl_copy = {} all_vars = [] for key, val in repl_conf.items(): all_vars = all_vars + re.findall(r'\${(\w+)}', key) all_vars = all_vars + re.findall(r'\${(\w+)}', key) all_vars = set(all_vars) if all_vars: print( f"{self.c.fg_bl}{len(all_vars)} variables detected in: {self.c.rs}{self.c.fg_yl}" f"{self._config_path}{self.c.rs}\n") template_vals = {} for var in all_vars: print(f"Template variable: {self.c.fg_bl}{var}{self.c.rs} found.") input_val = Input.input( f"Please input a value for {self.c.fg_bl}{var}{self.c.rs}: ", min_length=1) template_vals[var] = input_val for key, val in repl_conf.items(): updated_key = key updated_val = val for template_key, template_val in template_vals.items(): updated_key = updated_key.replace(f"${{{template_key}}}", template_val) updated_val = updated_val.replace(f"${{{template_key}}}", template_val) repl_copy[updated_key] = updated_val repl_copy[updated_key] = updated_val return repl_copy
def _delete_param(self): """ Prompts user for a parameter name to delete, then deletes """ # Add all keys key, notify, delete_another = None, False, True while delete_another: key = Input.input('PS Name to Delete: ', completer=self._config_completer) try: if self.delete_param(key): if key in self._config_completer.words: self._config_completer.words.remove(key) else: continue except ClientError as e: error_code = e.response['Error']['Code'] if "AccessDeniedException" == error_code: self._out.error(f"\n\nYou do not have permissions to delete config values at the path: [[{key}]]") self._out.warn(f"Your role of {self.context.role} may delete keys under the following namespaces: " f"{self._cfg_view.get_authorized_namespaces()}") self._out.print(f"Error message: {e.response['Error']['Message']}") elif "ParameterNotFound" == error_code: self._out.error(f"The specified Name: [[{key}]] does not exist in the selected environment. " f"Please try again.") else: self._out.error(f"Exception caught attempting to delete config: {e.response['Message']}") print() to_continue = input(f"Delete another? (Y/n): ") to_continue = to_continue if to_continue != '' else 'y' delete_another = to_continue.lower() == "y"
def edit(self) -> None: """ Allows a user to define a PS name and add or edit a parameter at that location. Uses NPYscreen editor. """ key = Input.input('Please input a PS Name: ', completer=self._config_completer) try: value, desc = self._ssm.get_parameter_with_description(key) edit_app = EditApp(key, value, desc) edit_app.run() value, desc = edit_app.value_box.value, edit_app.description_box.value log.info(f"Edited value: {value} - description: {desc}") is_secret = Input.is_secret() parameter_type, kms_id = SSM_SECURE_STRING if is_secret else SSM_STRING, None if is_secret: valid_keys = self._config_view.get_authorized_kms_keys() if len(valid_keys) > 1: key_name = Input.select_kms_key(valid_keys) else: key_name = valid_keys[0] kms_id = self._config_view.get_authorized_key_id( key_name, self.run_env) if not self._utils.is_valid_input(key, f"Parameter name", True) \ or not self._utils.is_valid_input(value, key, True): self._utils.error_exit( "Invalid input detected, please resolve the issue and retry." ) self._ssm.set_parameter(key, value, desc, parameter_type, key_id=kms_id) print(f"{self.c.fg_gr}{key} saved successfully.{self.c.rs}") except ClientError as e: if "AccessDeniedException" == e.response['Error']['Code']: denied = "AccessDeniedException" == e.response['Error']['Code'] if denied and "AWSKMS; Status Code: 400;" in e.response[ 'Error']['Message']: print( f"\n{self.c.fg_rd}You do not have access to decrypt the value of: {key}{self.c.rs}" ) elif denied: print( f"\n{self.c.fg_rd}You do not have access to Parameter: {key}{self.c.rs}" ) else: raise else: self._utils.error_exit( f"{self.c.fg_rd}Exception caught attempting to add config: {e}{self.c.rs}" )
def _generate(self): from_config = self._utils.get_ci_config(self._from_path) service_name = self._utils.get_namespace(from_config).split('/')[2] current_ns = self._utils.get_namespace(from_config) base_name, version = self._get_service_name_and_version(service_name) base_name = base_name if not base_name.endswith( '-') else base_name[:-1] new_service_name = f'{base_name}-{version + 1}' new_name = Input.input( f'Please select a new service name, it CANNOT be: {service_name}: ', default=new_service_name) self._utils.validate( new_name != service_name, f"You must select a new service name that differs from the one" f"designated in your source figgy.json file. " f"(NOT {service_name})") new_ns = f'{self.context.defaults.service_ns}/{new_name}/' # Update all configs destinations to leverage new namespace. Easiest to search/replace across everything. output_string = json.dumps(from_config) output_string = output_string.replace(current_ns[:-1], new_ns[:-1]) new_config = json.loads(output_string) # Remove existing configs that will be replicated new_config[CONFIG_KEY] = [] # Configure replicate_from block new_config[REPL_FROM_KEY] = { SOURCE_NS_KEY: from_config.get(REPL_FROM_KEY, {}).get(SOURCE_NS_KEY, current_ns), PARAMETERS_KEY: from_config.get(REPL_FROM_KEY, {}).get(PARAMETERS_KEY, []) } for param in from_config.get(CONFIG_KEY, []): new_config[REPL_FROM_KEY][PARAMETERS_KEY].append( self._utils.get_parameter_only(param)) formatted_config = self._utils.format_config(new_config) current_dir = getcwd() output_file = prompt(f'Write new config here?: ', default=f'{current_dir}/{new_name}-config.json') self._utils.is_valid_input(output_file, "output_file", True) with open(output_file, "w") as file: file.write(json.dumps(formatted_config, sort_keys=False, indent=4)) print( f'{self.c.fg_gr}New config successfully generated at location: {output_file}{self.c.rs}' )
def _promote(self): repeat = True parameters: List[Dict] = [] while repeat: namespace = Input.input("Please input a namespace prefix to promote:" f" (i.e. {self.context.defaults.service_ns}/foo/): ", completer=self._config_completer) if not self._utils.is_valid_input(namespace, "namespace", notify=False): continue try: parameters: List[Dict] = self._source_ssm.get_all_parameters([namespace]) if not parameters and self._source_ssm.get_parameter(namespace): parameters, latest_version = self._source_ssm.get_parameter_details(namespace) parameters = list(parameters) if parameters: repeat = False else: self._out.warn("\nNo parameters found. Try again.\n") except ClientError as e: print(f"{self.c.fg_rd}ERROR: >> {e}{self.c.rs}") continue self._out.notify(f'\nFound [[{len(parameters)}]] parameter{"s" if len(parameters) > 1 else ""} to migrate.\n') assumable_roles = self.context.defaults.assumable_roles matching_roles = list(set([x for x in assumable_roles if x.role == self.config_context.role])) valid_envs = set([x.run_env.env for x in matching_roles]) valid_envs.remove(self.run_env.env) # Remove current env, we can't promote from dev -> dev next_env = Input.select(f'Please select the destination environment.', valid_options=list(valid_envs)) matching_role = [role for role in matching_roles if role.run_env == RunEnv(env=next_env)][0] env: GlobalEnvironment = GlobalEnvironment(role=matching_role, region=self.config_context.defaults.region) dest_ssm = SsmDao(self._session_mgr.get_session(env, prompt=False).client('ssm')) for param in parameters: if 'KeyId' in param: self._out.print(f"Skipping param: [[{param['Name']}]]. It is encrypted and cannot be migrated.") else: promote_it = Input.y_n_input(f"Would you like to promote: {param['Name']}?", default_yes=True) if promote_it: val = self._source_ssm.get_parameter(param['Name']) description = param.get('Description', "") dest_ssm.set_parameter(param['Name'], val, description, SSM_STRING) self._out.success(f"Successfully promoted [[{param['Name']}]] to [[{next_env}]].\r\n")
def _audit(self): audit_more = True while audit_more: ps_name = Input.input(f"Please input a PS Name : ", completer=self._config_completer) audit_logs = self._audit_dao.get_audit_logs(ps_name) result_count = len(audit_logs) if result_count > 0: self._out.print(f"\nFound [[{result_count}]] results.") else: self._out.warn(f"\nNo results found for: [[{ps_name}]]") for log in audit_logs: self._out.print(log.pretty_print()) to_continue = input(f"Audit another? (Y/n): ") to_continue = to_continue if to_continue != '' else 'y' audit_more = to_continue.lower() == "y" print()
def get_app_link() -> str: app_link = Input.input("Please input your OKTA AWS Application Embed Link. It's usually something like " "'https://your-company.okta.com/home/amazon_aws/ASDF12351fg1/234': ") Utils.stc_is_valid_input(app_link, "OKTA AWS Application URL", True) return app_link
def put_param(self, key=None, loop=False, display_hints=True) -> None: """ Allows a user to define a PS name and add a new parameter at that named location. User will be prompted for a value, desc, and whether or not the parameter is a secret. If (Y) is selected for the secret, will encrypt the value with the appropriately mapped KMS key with the user's role. :param key: If specified, the user will be prompted for the specified key. Otherwise the user will be prompted to specify the PS key to set. :param loop: Whether or not to continually loop and continue prompting the user for more keys. :param display_hints: Whether or not to display "Hints" to the user. You may want to turn this off if you are looping and constantly calling put_param with a specified key. """ value, desc, notify, put_another = True, None, False, True if display_hints: self._out.print( f"[[Hint:]] To upload a file's contents, pass in `file:///path/to/your/file` " f"in the value prompt.") while put_another: try: if not key: lexer = PygmentsLexer( FigLexer ) if self.context.defaults.colors_enabled else None style = style_from_pygments_cls( FiggyPygment ) if self.context.defaults.colors_enabled else None key = Input.input( 'Please input a PS Name: ', completer=self._config_view.get_config_completer(), lexer=lexer, style=style) if self.parameter_is_existing_dir(key): self._out.warn( f'You attempted to store parameter named: {key},' f' but it already exists in ParameterStore as a directory: {key}/' ) key = None continue if self._source_key: plain_key = '/'.join(key.strip('/').split('/')[2:]) source_key = f'{self._source_key}/{plain_key}' orig_value, orig_description = self._get.get_val_and_desc( source_key) else: orig_description = '' orig_value = '' value = Input.input(f"Please input a value for {key}: ", default=orig_value if orig_value else '') if value.lower().startswith(self._FILE_PREFIX): value = Utils.load_file( value.replace(self._FILE_PREFIX, "")) existing_desc = self._ssm.get_description(key) desc = Input.input( f"Please input an optional description: ", optional=True, default=existing_desc if existing_desc else orig_description if orig_description else '') is_secret = Input.is_secret() parameter_type, kms_id = SSM_SECURE_STRING if is_secret else SSM_STRING, None if is_secret: valid_keys = self._config_view.get_authorized_kms_keys() if len(valid_keys) > 1: key_name = Input.select_kms_key(valid_keys) else: key_name = valid_keys[0] kms_id = self._config_view.get_authorized_key_id( key_name, self.run_env) notify = True self._ssm.set_parameter(key, value, desc, parameter_type, key_id=kms_id) if key not in self._config_view.get_config_completer().words: self._config_view.get_config_completer().words.append(key) except ClientError as e: if "AccessDeniedException" == e.response['Error']['Code']: self._out.error( f"\n\nYou do not have permissions to add config values at the path: [[{key}]]" ) self._out.warn( f"Your role of {self.context.role} may add keys under the following namespaces: " f"{self._config_view.get_authorized_namespaces()}") self._out.print( f"Error message: {e.response['Error']['Message']}") else: self._out.error( f"Exception caught attempting to add config: {e}") print() if loop: to_continue = input(f"\nAdd another? (y/N): ") put_another = True if to_continue.lower() == 'y' else False key = None else: put_another = False
def _restore_params_to_point_in_time(self): """ Restores parameters as they were to a point-in-time as defined by the time provided by the users. Replays parameter history to that point-in-time so versioning remains intact. """ repl_destinations = [] ps_prefix = Input.input( f"Which parameter store prefix would you like to recursively restore? " f"(e.g., /app/demo-time): ", completer=self._config_completer) authed_nses = self._cfg_view.get_authorized_namespaces() valid_prefix = ( [True for ns in authed_nses if ps_prefix.startswith(ns)] or [False])[0] self._utils.validate( valid_prefix, f"Selected namespace must begin with a 'Fig Tree' you have access to. " f"Such as: {authed_nses}") time_selected, time_converted = None, None try: time_selected = Input.input( "Seconds since epoch to restore latest values from: ") time_converted = datetime.fromtimestamp(float(time_selected)) except ValueError as e: if "out of range" in e.args[0]: try: time_converted = datetime.fromtimestamp( float(time_selected) / 1000) except ValueError as e: self._utils.error_exit( "Make sure you're using a format of either seconds or milliseconds since epoch." ) elif "could not convert" in e.args[0]: self._utils.error_exit( f"The format of this input should be seconds since epoch. (e.g., 1547647091)\n" f"Try using: https://www.epochconverter.com/ to convert your date to this " f"specific format.") else: self._utils.error_exit( "An unexpected exception triggered: " f"'{e}' while trying to convert {time_selected} to 'datetime' format." ) self._utils.validate( time_converted is not None, f"`{CLI_NAME}` encountered an error parsing your input for " f"target rollback time.") keep_going = Input.y_n_input( f"Are you sure you want to restore all figs under {ps_prefix} values to their state at: " f"{time_converted}? ", default_yes=False) if not keep_going: self._utils.warn_exit("Aborting restore due to user selection") ps_history: PSHistory = self._audit.get_parameter_history_before_time( time_converted, ps_prefix) restore_count = len(ps_history.history.values()) if len(ps_history.history.values()) == 0: self._utils.warn_exit( "No results found for time range. Aborting.") last_item_name = 'Unknown' try: for item in ps_history.history.values(): last_item_name = item.name if self._is_replication_destination(item.name): repl_destinations.append(item.name) continue if item.cfg_at(time_converted).ps_action == SSM_PUT: cfgs_before: List[RestoreConfig] = item.cfgs_before( time_converted) cfg_at: RestoreConfig = item.cfg_at(time_converted) ssm_value = self._ssm.get_parameter(item.name) dynamo_value = self._decrypt_if_applicable(cfg_at) if ssm_value != dynamo_value: if ssm_value is not None: self._ssm.delete_parameter(item.name) for cfg in cfgs_before: decrypted_value = self._decrypt_if_applicable(cfg) self._out.print( f"\nRestoring: [[{cfg.ps_name}]] \nValue: [[{decrypted_value}]]" f"\nDescription: [[{cfg.ps_description}]]\nKMS Key: " f"[[{cfg.ps_key_id if cfg.ps_key_id else '[[No KMS Key Specified]]'}]]" ) self._out.notify( f"Replaying version: [[{cfg.ps_version}]] of [[{cfg.ps_name}]]" ) print() self._ssm.set_parameter(cfg.ps_name, decrypted_value, cfg.ps_description, cfg.ps_type, key_id=cfg.ps_key_id) else: self._out.success( f"Config: {item.name} is current. Skipping.") else: # This item must have been a delete, which means this config didn't exist at that time. self._out.print( f"Checking if [[{item.name}]] exists. It was previously deleted." ) self._prompt_delete(item.name) except ClientError as e: if "AccessDeniedException" == e.response["Error"]["Code"]: self._utils.error_exit( f"\n\nYou do not have permissions to restore config at the path:" f" [[{last_item_name}]]") else: self._utils.error_exit( f"Caught error when attempting restore. {e}") for item in repl_destinations: cfg = self._repl.get_config_repl(item) self._print_cannot_restore_msg(cfg) print("\n\n") if not repl_destinations: self._out.success_h2( f"[[{restore_count}]] configurations restored successfully!") else: self._out.warn( f"\n\n[[{len(repl_destinations)}]] configurations were not restored because they are shared " f"from other destinations. To restore them, restore their sources." ) self._out.success( f"{restore_count - len(repl_destinations)} configurations restored successfully." )
def login_sandbox(self): """ If user provides --role flag, skip role & env selection for a smoother user experience. """ EnvironmentValidator(self._defaults).validate_environment_variables() Utils.wipe_vaults() or Utils.wipe_defaults( ) or Utils.wipe_config_cache() self._out.print( f"{self.c.fg_bl}Logging you into the Figgy Sandbox environment.{self.c.rs}" ) user = Input.input("Please input a user name: ", min_length=2) colors = Input.select_enable_colors() # Prompt user for role if --role not provided if commands.role not in self.context.options: role = Input.select("\n\nPlease select a role to impersonate: ", valid_options=SANDBOX_ROLES) else: role = self.context.role.role self._utils.validate( role in SANDBOX_ROLES, f"Provided role: >>>`{role}`<<< is not a valid sandbox role." f" Please choose from {SANDBOX_ROLES}") params = {'role': role, 'user': user} result = requests.get(GET_SANDBOX_CREDS_URL, params=params) if result.status_code != 200: self._utils.error_exit( "Unable to get temporary credentials from the Figgy sandbox. If this problem " f"persists please notify us on our GITHUB: {FIGGY_GITHUB}") data = result.json() response = SandboxLoginResponse(**data) self._aws_cfg.write_credentials( access_key=response.AWS_ACCESS_KEY_ID, secret_key=response.AWS_SECRET_ACCESS_KEY, token=response.AWS_SESSION_TOKEN, region=FIGGY_SANDBOX_REGION, profile_name=FIGGY_SANDBOX_PROFILE) defaults = CLIDefaults.sandbox(user=user, role=role, colors=colors) self._setup.save_defaults(defaults) run_env = RunEnv( env='dev', account_id=SANDBOX_DEV_ACCOUNT_ID) if self.context.role else None config_mgr = ConfigManager.figgy() config_mgr.set(Config.Section.Bastion.PROFILE, FIGGY_SANDBOX_PROFILE) defaults = self._setup.configure_extras(defaults) defaults = self._setup.configure_roles(current_defaults=defaults, role=Role(role=role), run_env=run_env) defaults = self._setup.configure_figgy_defaults(defaults) self._setup.save_defaults(defaults) self._out.success( f"\nLogin successful. Your sandbox session will last for [[1 hour]]." ) self._out.print( f"\nIf your session expires, you may rerun `{CLI_NAME} login sandbox` to get another sandbox session. " f"\nAll previous figgy sessions have been disabled, you'll need to run {CLI_NAME} " f"--configure to leave the sandbox.")