def configure_roles(self, current_defaults: CLIDefaults, run_env: RunEnv = None, role: Role = None) -> CLIDefaults: updated_defaults = current_defaults provider_factory: SessionProviderFactory = SessionProviderFactory(current_defaults, self._figgy_context) session_provider: SSOSessionProvider = provider_factory.instance() session_provider.cleanup_session_cache() # Get assertion and parse out account -> role -> run_env mappings. assumable_roles: List[AssumableRole] = session_provider.get_assumable_roles() print(f"\n{self.c.fg_bl}The following assumable roles were detected for user: {current_defaults.user} " f"- if something is missing, contact your system administrator.{self.c.rs}\n\n") if assumable_roles: self.print_role_table(assumable_roles) valid_envs = list(set([x.run_env.env for x in assumable_roles])) valid_roles = list(set([x.role.role for x in assumable_roles])) if not role: role: Role = Input.select_role(valid_roles=valid_roles) print("\n") if not run_env: run_env: RunEnv = Input.select_default_account(valid_envs=valid_envs) print("\n") else: print(f"\nYour default environment has been set to: {run_env}. Commands without the " f"--{env.name} option will run against this account.") updated_defaults.run_env = run_env updated_defaults.valid_envs = valid_envs updated_defaults.valid_roles = valid_roles updated_defaults.assumable_roles = assumable_roles updated_defaults.role = role return updated_defaults
def _perform_deletions(self) -> None: deleted = [] # type: List[str] for path in self.deleted_ps_paths: if path in self.dirs: all_children = set( list( map( lambda x: x['Name'], self._ssm.get_all_parameters([path], option='Recursive')))) delete_children = Input.y_n_input( f"You have selected a DIRECTORY to delete: {path}. " f"Do you want to delete ALL children of: {path}?", default_yes=False) if delete_children: for child in all_children: if self._delete.delete_param(child): deleted.append(child) else: if path not in deleted: delete_it = Input.y_n_input(f"Delete {path}? ", default_yes=True) if delete_it: if self._delete.delete_param(path): deleted.append(path)
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 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 _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 _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 _cleanup_parameters(self, config_keys: Set): """ Prompts user for prune of stray ParameterStore names. Args: config_keys: set() -> Set of parameters that are found as defined in the figgy.json file for a svc """ self._out.notify(f"Checking for stray config names.\r\n") # Find & Prune stray keys ps_keys = set( list( map(lambda x: x['Name'], self._ssm.get_all_parameters([self._namespace])))) ps_only_keys = ps_keys.difference(config_keys) for key in ps_only_keys: selection = Input.y_n_input( f"{key} exists in ParameterStore but does not exist " f"in your config, do you want to delete it?", default_yes=False) if selection: self._delete_command.delete_param(key) else: self._out.notify("OK, skipping due to user selection.") if not ps_only_keys: print(f"{self.c.fg_bl}No stray keys found.{self.c.rs}")
def install_mac(self, latest_version: FiggyVersionDetails) -> bool: install_path = '/usr/local/bin/figgy' if self.upgrade_mgr.is_brew_install(): self._out.notify_h2(f"Homebrew installation detected!") print( f"This upgrade process will not remove your brew installation but will instead unlink it. " f"Going forward you will no longer need homebrew to manage {CLI_NAME}. Continuing is recommended.\n" ) selection = Input.y_n_input(f"Continue? ", default_yes=True) else: selection = True if selection: self.upgrade_mgr.install_onedir(install_path, latest_version.version, MAC) return True else: self._out.print( f'\n[[Auto-upgrade aborted. To upgrade through brew run:]] \n' f'-> brew upgrade figtools/figgy/figgy') self._out.warn( f"\n\nYou may continue to manage [[{CLI_NAME}]] through Homebrew, but doing so will " f"limit some upcoming functionality around canary releases, rollbacks, and dynamic " f"version-swapping.") return False
def log_error(self, command: str, e: Exception): os.makedirs(ERROR_LOG_DIR, exist_ok=True) # Do not sanitize here since this is only written to the user's local log folder. printable_exception = ''.join(traceback.format_exception(etype=type(e), value=e, tb=e.__traceback__)) log_file_name = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S.log") with open(f'{ERROR_LOG_DIR}/{log_file_name}', "w+") as log: log.write(f'Command: {command}\n\n\n{printable_exception}') print(f"\n\n{self.c.fg_bl}---------------------ERROR ENCOUNTERED --------------------{self.c.rs}\n\n") print(f"{self.c.fg_rd}Figgy experienced the following irrecoverable error. " f"Please consider reporting this error.{self.c.rs}") print(printable_exception) print(f"\n\n{self.c.fg_bl}-----------------------------------------------------------{self.c.rs}\n\n") if self.reporting_enabled: ship_it = Input.y_n_input("Would you like to report this to Figgy's developer(s)?", default_yes=True) if ship_it: self.report_error(command, e) else: print(f"Error was not reported. Please consider reporting this error on our Github: " f"{FIGGY_GITHUB}. Error details have been logged to this file: " f"{self.c.fg_bl}~/.{CLI_NAME}/errors/{log_file_name}{self.c.rs}. Farewell! \n")
def get_role(self, prompt: bool, role_override: str = None, is_setup: bool = False) -> Role: """ Returns a string of the user's selected role. Lookup the user's default role from the config file (created via the --configure option), an ENV variable, or instead prompt the user for the session. :param prompt: True/False - if True, users will always be prompted to input their role :param role_override: String representation of the role to get, regardless of defaults. :return: str: name of the selected role. """ defaults = FiggySetup.stc_get_defaults(self._is_setup_command, profile=self._profile) if defaults is not None and not prompt: if role_override: if role_override in [role.role.role for role in defaults.assumable_roles] or is_setup: return Role(role=role_override) else: self._utils.error_exit(f"Invalid role override provided of: {role_override}. " f"You do not have permissions to assume this role. Contact your system " f"administrator to receive permissions then rerun `{CLI_NAME} " f"--{configure.name}`.") return defaults.role else: roles = self.__setup().get_assumable_roles() role_names = list(set([x.role.role for x in roles])) return Input.select_role(role_names)
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 _prompt_delete(self, name): param = self._ssm.get_parameter_encrypted(name) if param: selection = Input.y_n_input( f"PS Name: {name} did not exist at this restore time." f" Delete it? ", default_yes=False) if selection: self._delete.delete_param(name)
def get_saml_assertion(self, prompt: bool = False, mfa: Optional[str] = None) -> str: """ Lookup OKTA session from cache, if it's valid, use it, otherwise, generate new assertion with MFA Args: prompt: Used for forcing prompts of username / password and always generating a new assertion mfa: MFA to use for generating the new OKTA session with. force_new: Forces a new session, abandons one from cache """ log.info(f'Getting SAML assertion. Provided MFA override: {mfa}') invalid_session = True okta = self.get_sso_session(prompt, mfa) failure_count = 0 # Todo: is this an infinite loop after a request from UI with a bad MFA? while invalid_session: try: assertion = okta.get_assertion() except InvalidSessionError as e: if failure_count > 0: print(e) print( "Authentication failed with SSO provider, please reauthenticate" " Likely invalid MFA or Password?\r\n") failure_count += 1 log.debug(f" invalid session: {e}") user = self._get_user(prompt) password = self._get_password(user, prompt=prompt, save=True) if self._defaults.mfa_enabled: color = Utils.default_colors( ) if self._defaults.colors_enabled else None mfa = self._secrets_mgr.get_next_mfa(user) if self._defaults.auto_mfa else \ Input.get_mfa(display_hint=True, color=color) else: mfa = None primary_auth = OktaPrimaryAuth(self._defaults, password, mfa) try: print("Trying to write session to cache...") self._write_okta_session_to_cache( primary_auth.get_session()) except InvalidSessionError as e: print(f"Got invalid session: {e}") return self.get_saml_assertion(prompt=True) else: return self.get_saml_assertion(prompt=True) else: assertion = base64.b64decode(assertion).decode('utf-8') self._saml_cache.write(SAML_ASSERTION_CACHE_KEY, assertion) return assertion
def get_or_prompt(self, key: Enum, get_it: Callable, colors_enabled=Utils.is_mac(), force_prompt=False, desc: str = None) -> str: """ Retrieves a value from the config_file based on the provided ENUM's value. If the value is unset, executes the get_it() method provided to retrieve the value, then returns it. If the user selects something _other_ than the default, overwrite the original value in the config file :param key: Enum representing the config value to fetch :param get_it: Method to execute if `config` is not in the configured config_file :param colors_enabled: Whether or not to enable colored output for the prompt if a config is found in config_file :param optional description to provide context when user is prompted. :return: String value from the config file, or the result of get_it() """ c = TerminalFactory(colors_enabled).instance().get_colors() val = self.get_property(key) selection = None if val or force_prompt: if not force_prompt: print(f"\n\n{c.fg_bl}Default value found:{c.rs}") print(f"Key: {c.fg_gr}{key.value}{c.rs}") print(f"Value: {c.fg_gr}{val}{c.rs}") if desc: print(f"Description: {desc}") selection = Input.y_n_input(f"Continue with `{val}`? ", default_yes=True) if not selection or force_prompt: original_val = val val = get_it() if isinstance(val, bool): val = "true" if val else "false" if val != original_val: print( f"\n\nYou selected to overwrite the default. Updating default value:" f"\nKey: {c.fg_gr}{key.value}{c.rs}" f"\nFrom: {c.fg_yl}{original_val}{c.rs}" f"\nTo: {c.fg_gr}{val}{c.rs}" f"\nIn file {c.fg_bl}{self.config_file}{c.rs}") self.set(key, val) else: val = get_it() return val
def _get_user(self, prompt: bool) -> str: """ Get the user either from cache, or prompt the user. Returns: str -> username """ defaults = self._defaults if defaults is not None and not prompt: return defaults.user else: return Input.get_user(provider=self._defaults.provider.name)
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 configure_auth(self, current_defaults: CLIDefaults, configure_provider=True) -> CLIDefaults: updated_defaults = current_defaults if configure_provider or current_defaults.provider is Provider.UNSELECTED: provider: Provider = Input.select_provider() updated_defaults.provider = provider else: provider: Provider = current_defaults.provider if provider in Provider.sso_providers(): user: str = Input.get_user(provider=provider.name) password: str = Input.get_password(provider=provider.name) self._secrets_mgr.set_password(user, password) updated_defaults.user = user try: mfa_enabled = Utils.parse_bool(self._config_mgr.get_or_prompt(Config.Section.Figgy.MFA_ENABLED, Input.select_mfa_enabled, desc=MFA_DESC)) if mfa_enabled: auto_mfa = Utils.parse_bool(self._config_mgr.get_or_prompt(Config.Section.Figgy.AUTO_MFA, Input.select_auto_mfa, desc=AUTO_MFA_DESC)) else: auto_mfa = False except ValueError as e: Utils.stc_error_exit(f"Invalid value found in figgy defaults file under " f"{Config.Section.Figgy.MFA_ENABLED.value}. It must be either 'true' or 'false'") else: updated_defaults.mfa_enabled = mfa_enabled updated_defaults.auto_mfa = auto_mfa if updated_defaults.auto_mfa: mfa_secret = Input.get_mfa_secret() self._secrets_mgr.set_mfa_secret(updated_defaults.user, mfa_secret) if configure_provider: provider_config = ProviderConfigFactory().instance(provider, mfa_enabled=updated_defaults.mfa_enabled) updated_defaults.provider_config = provider_config return updated_defaults
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_password(self, user_name, prompt: bool, save: bool = False) -> str: """ Get the password either from keyring, or prompt the user. Returns: str -> password """ password = self._secrets_mgr.get_password(user_name) reset_password = not password if reset_password or prompt: password = Input.get_password( provider=self._defaults.provider.name) if reset_password or save: self._secrets_mgr.set_password(user_name, password) return password
def get_profile(self, prompt: bool) -> str: """Returns the user's profile. Checks ENV variable, if not there, checks the config file (created via the --configure option), otherwise prompts the user Args: prompt: True/False - if True, users will always be prompted to input their profile :return: str: aws profile name """ if BASTION_PROFILE_ENV_NAME in os.environ and not prompt: return os.environ.get(BASTION_PROFILE_ENV_NAME) else: defaults: CLIDefaults = FiggySetup.stc_get_defaults(self._is_setup_command, profile=self._profile) if defaults is not None and not prompt: return defaults.provider_config.profile else: return Input.select_aws_cli_profile()
def handle_totp(self, sess): response_page = BeautifulSoup(sess.text, 'html.parser') tl = response_page.find('input', {'name': 'TL'}).get('value') gxf = response_page.find('input', {'name': 'gxf'}).get('value') challenge_url = sess.url.split("?")[0] challenge_id = challenge_url.split("totp/")[1] if self._defaults.mfa_enabled: color = Utils.default_colors( ) if self._defaults.colors_enabled else None mfa_token = self._secrets_mgr.get_next_mfa(self._defaults.user) if self._defaults.auto_mfa else \ Input.get_mfa(display_hint=True, color=color) else: mfa_token = None if not mfa_token: raise ValueError( "MFA token required for {} but none supplied.".format( self.config.username)) payload = { 'challengeId': challenge_id, 'challengeType': 6, 'continue': self.cont, 'scc': 1, 'sarp': 1, 'checkedDomains': 'youtube', 'pstMsg': 0, 'TL': tl, 'gxf': gxf, 'Pin': mfa_token, 'TrustDevice': 'on', } # Submit TOTP return self.post(challenge_url, data=payload)
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 get_session(self, env: GlobalEnvironment, prompt: bool, exit_on_fail=True, mfa: Optional[str] = None) -> boto3.Session: forced = False log.info( f"Getting session for role: {env.role.role_arn} in env: {env.role.run_env.env}" ) attempts = 0 while True: try: if prompt and not forced: forced = True raise InvalidSessionError( "Forcing new session due to prompt.") creds: FiggyAWSSession = self._sts_cache.get_val( env.role.cache_key()) if creds: session = boto3.Session( aws_access_key_id=creds.access_key, aws_secret_access_key=creds.secret_key, aws_session_token=creds.token, region_name=env.region) if creds.expires_soon( ) or not self._is_valid_session(session): self._utils.validate( attempts < self._MAX_ATTEMPTS, f"Failed to authenticate with AWS after {attempts} attempts. Exiting. " ) attempts = attempts + 1 log.info( "Invalid session detected in cache. Raising session error." ) raise InvalidSessionError("Invalid Session Detected") log.info("Valid bastion SSO session returned from cache.") return session else: raise InvalidSessionError( "Forcing new session, cache is empty.") except (FileNotFoundError, NoCredentialsError, InvalidSessionError) as e: try: if self._defaults.mfa_enabled: self._defaults.mfa_serial = self.get_mfa_serial() color = Utils.default_colors( ) if self._defaults.colors_enabled else None if not mfa: if self._context.command == commands.ui and not self._defaults.auto_mfa: raise CannotRetrieveMFAException( "Cannot retrieve MFA, UI mode is activated." ) else: mfa = self._secrets_mgr.get_next_mfa(self._defaults.user) if self._defaults.auto_mfa else \ Input.get_mfa(display_hint=True, color=color) response = self.__get_sts().assume_role( RoleArn=env.role.role_arn, RoleSessionName=Utils.sanitize_session_name( self._defaults.user), DurationSeconds=self._defaults.session_duration, SerialNumber=self._defaults.mfa_serial, TokenCode=mfa) else: response = self.__get_sts().assume_role( RoleArn=env.role.role_arn, RoleSessionName=Utils.sanitize_session_name( self._defaults.user), DurationSeconds=self._defaults.session_duration) session = FiggyAWSSession( **response.get('Credentials', {})) log.info(f"Got session response: {response}") self._sts_cache.write(env.role.cache_key(), session) except (ClientError, ParamValidationError) as e: if isinstance( e, ParamValidationError ) or "AccessDenied" == e.response['Error']['Code']: if exit_on_fail: self._utils.error_exit( f"Error authenticating with AWS from Bastion Profile:" f" {self._defaults.provider_config.profile_name}: {e}" ) else: if exit_on_fail: log.error( f"Failed to authenticate due to error: {e}") self._utils.error_exit( f"Error getting session for role: {env.role.role_arn} " f"-- Are you sure you have permissions?") raise e
def upgrade(self): latest_version: FiggyVersionDetails = self.tracker.get_version() install_success, upgrade_it = False, True if self.upgrade_mgr.is_pip_install(): self._out.error( f"Figgy appears to have been installed with pip. Please upgrade [[{CLI_NAME}]] with " f"`pip` instead.") self._out.print( f"\n\n[[Try this command]]: pip install figgy-cli --upgrade") self._out.print( f"\n\nPip based [[{CLI_NAME}]] installations do not support automatic upgrades and " f"instead require pip-managed upgrades; however, Homebrew, one-line, and manual " f"installations support auto-upgrade. Please consider installing figgy through one " f"of these other methods to take advantage of this feature. " f"It will save you time, help keep you up-to-date, and enable important features like " f"release-rollbacks and canary releases! " f"[[https://www.figgy.dev/docs/getting-started/install/]]") sys.exit(0) install_path = self.upgrade_mgr.install_path if not install_path: self._utils.error_exit( f"Unable to detect local figgy installation. Please reinstall figgy and follow one " f"of the recommended installation procedures.") if latest_version.version == VERSION: self._out.success( f'You are currently using the latest version of [[{CLI_NAME}]]: [[{VERSION}]]' ) upgrade_it = False elif self.tracker.upgrade_available(): self._out.notify_h2( f"New version: [[{latest_version.version}]] is more recent than your version: [[{VERSION}]]" ) upgrade_it = True elif not self.tracker.cloud_version_compatible_with_upgrade(): self._out.notify_h2( f"Version [[{self.tracker.get_version().version}]] of the Figgy CLI is available but your " f"current version of Figgy Cloud ([[{self.tracker.current_cloud_version()}]]) is not compatible." f" Your administrator must first update FiggyCloud to at least version: " f"[[{self.tracker.required_cloud_version()}]] before you can upgrade Figgy." ) upgrade_it = False else: self._out.notify_h2( f"Your version: [[{VERSION}]] is more recent then the current recommended version " f"of {CLI_NAME}: [[{latest_version.version}]]") upgrade_it = Input.y_n_input( f'Would you like to revert to the current recommended version ' f'of {CLI_NAME}?') if upgrade_it: if self._utils.is_mac(): self._out.print( f"\nMacOS auto-upgrade is supported. Performing auto-upgrade." ) install_success = self.install_mac(latest_version) elif self._utils.is_linux(): self._out.print( f"\nLinux auto-upgrade is supported. Performing auto-upgrade." ) install_success = self.install_linux(latest_version) elif self._utils.is_windows(): self._out.print( f"\nWindows auto-upgrade is supported. Performing auto-upgrade." ) install_success = self.install_windows(latest_version) if install_success: self._out.success( f"Installation successful! Exiting. Rerun `[[{CLI_NAME}]]` " f"to use the latest version!") else: self._out.warn( f"\nUpgrade may not have been successful. Check by re-running " f"[[`{CLI_NAME}` --version]] to see if it was. If it wasn't, please reinstall [[`{CLI_NAME}`]]. " f"See {INSTALL_URL}.")
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 get_sso_session(self, prompt: bool = False, mfa: Optional[str] = None) -> Okta: """ Pulls the last okta session from cache, if cache doesn't exist, generates a new session and writes it to cache. From this session, the OKTA SVC is hydrated and returned. Args: prompt: If supplied, will never get session from cache. Returns: Initialized Okta service. """ count = 0 while True: try: if prompt: raise InvalidSessionError( "Forcing new session due to prompt.") cached_session = self._get_session_from_cache() if not cached_session: raise InvalidSessionError("No session found in cache.") okta = Okta(OktaSessionAuth(self._defaults, cached_session)) return okta except (FileNotFoundError, InvalidSessionError, JSONDecodeError, AttributeError) as e: try: password = self._secrets_mgr.get_password( self._defaults.user) if not mfa: if self._context.command == commands.ui and not self._defaults.auto_mfa: raise CannotRetrieveMFAException( "Cannot retrieve MFA, UI mode is activated.") else: color = Utils.default_colors( ) if self._defaults.colors_enabled else None mfa = self._secrets_mgr.get_next_mfa(self._defaults.user) if self._defaults.auto_mfa else \ Input.get_mfa(display_hint=True, color=color) log.info(f"Getting OKTA primary auth with mfa: {mfa}") primary_auth = OktaPrimaryAuth(self._defaults, password, mfa) self._write_okta_session_to_cache( primary_auth.get_session()) return Okta(primary_auth) except InvalidSessionError as e: prompt = True log.error( f"Caught error when authing with OKTA & caching session: {e}. " ) time.sleep(1) count += 1 if count > 1: if self._context.command == ui: raise InvalidCredentialsException( "Failed OKTA authentication. Invalid user, password, or MFA." ) else: Utils.stc_error_exit( "Unable to autheticate with OKTA with your provided credentials. Perhaps your" f"user, password, or MFA changed? Try rerunning `{CLI_NAME} --configure` again." )
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.")
def _restore_param(self) -> None: """ Allow the user to query a parameter store entry from dynamo, so we can query + restore it, if desired. """ table_entries = [] ps_name = prompt(f"Please input PS key to restore: ", completer=self._config_completer) if self._is_replication_destination(ps_name): repl_conf = self._repl.get_config_repl(ps_name) self._print_cannot_restore_msg(repl_conf) exit(0) self._out.notify( f"\n\nAttempting to retrieve all restorable values of [[{ps_name}]]" ) items: List[RestoreConfig] = self._audit.get_parameter_restore_details( ps_name) if len(items) == 0: self._out.warn( "No restorable values were found for this parameter.") return for i, item in enumerate(items): date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(item.ps_time / 1000)) # we need to decrypt the value, if encrypted, in order to show it to the user if item.ps_key_id: item.ps_value = self._kms.decrypt_with_context( item.ps_value, {"PARAMETER_ARN": self.get_parameter_arn(item.ps_name)}, ) table_entries.append([i, date, item.ps_value, item.ps_user]) self._out.print( tabulate( table_entries, headers=["Item #", "Time Created", "Value", "User"], tablefmt="grid", numalign="center", stralign="left", )) valid_options = [f'{x}' for x in range(0, len(items))] choice = int( Input.select("Select an item number to restore: ", valid_options=valid_options)) item = items[choice] if items[choice] else None restore = Input.y_n_input( f"Are you sure you want to restore item #{choice} and have it be the latest version? ", default_yes=False) if not restore: self._utils.warn_exit("Restore aborted.") key_id = None if item.ps_type == "String" else item.ps_key_id try: self._ssm.set_parameter(item.ps_name, item.ps_value, item.ps_description, item.ps_type, key_id=key_id) current_value = self._ssm.get_parameter(item.ps_name) if current_value == item.ps_value: self._out.success("Restore was successful") else: self._out.error( "Latest version in parameter store doesn't match what we restored." ) self._out.print( f"Current value: [[{current_value}]]. Expected value: [[{item.ps_value}]]" ) except ClientError as e: self._client_exception_msg(item, e)
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." )