#! /usr/bin/env perl # vim: set filetype=perl ts=4 sw=4 sts=4 et: use common::sense; use Sys::Hostname (); use Term::ANSIColor; use JSON; use POSIX (); use File::Basename; use lib dirname(__FILE__) . '/../../../lib/perl'; use OVH::Result; use OVH::Bastion; use OVH::Bastion::Plugin qw( :DEFAULT help ); # globally allow sys_getpw* and sys_getgr* cache use $ENV{'PW_GR_CACHE'} = 1; my $withGroups = 0; my $withPasswordInfo = 0; my $withEgressKeys = 0; sub toggle_all { my $v = shift; $withGroups = $v; $withPasswordInfo = $v; $withEgressKeys = $v; return; } OVH::Bastion::Plugin::begin( argv => \@ARGV, header => "account information", options => { 'account=s' => \my $account, "all" => \my $all, "list-groups|with-groups" => sub { $withGroups = 1 }, "without-groups" => sub { $withGroups = 0 }, 'with-mfa-password-info' => sub { $withPasswordInfo = 1 }, 'without-mfa-password-info' => sub { $withPasswordInfo = 0 }, 'with-egress-keys' => sub { $withEgressKeys = 1 }, 'without-egress-keys' => sub { $withEgressKeys = 0 }, 'with-everything' => sub { toggle_all(1) }, 'without-everything' => sub { toggle_all(0) }, }, helptext => <<'EOF', Display some information about an account Usage: --osh SCRIPT_NAME <--account ACCOUNT|--all> [OPTIONS] --account ACCOUNT The account name to work on --all Dump info for all accounts (auditors only), use with ``--json`` --with[out]-everything Include or exclude all below options, including future ones --with[out]-groups Whether to include the groups the account has a role on (SLOW, default: no) --with[out]-mfa-password-info Whether to include MFA password info of the account (SLOW, auditors only, default: no) --with[out]-egress-keys Whether to include the account's egress keys (SLOW, auditors only, default: no) EOF ); my $fnret; # check params if ($account && $all) { osh_exit('ERR_INCOMPATIBLE_PARAMETERS', msg => "Can't use both --account and --all"); } if (($all || $withPasswordInfo || $withEgressKeys) && !OVH::Bastion::is_auditor(account => $self)) { osh_exit('ERR_ACCESS_DENIED', msg => "This option can only be used by bastion auditors"); } if (!$account && !$all) { help(); osh_exit('ERR_MISSING_PARAMETER', msg => "Missing either --account or --all parameter"); } # gather all accounts if $all, or only use the user-specified account if !$all my @accountsToCheck; if ($all) { $fnret = OVH::Bastion::get_account_list(); $fnret or osh_exit $fnret; @accountsToCheck = sort keys %{$fnret->value}; osh_info("Gathering data, this may take a few seconds..."); } else { @accountsToCheck = ($account); } # validate each account and get their corresponding sys/remote name, while also untainting it my @accounts; foreach my $anAccount (@accountsToCheck) { $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $anAccount); $fnret or osh_exit $fnret; $account = $fnret->value->{'account'}; my $sysaccount = $fnret->value->{'sysaccount'}; my $remoteaccount = $fnret->value->{'remoteaccount'}; push @accounts, {account => $account, sysaccount => $sysaccount, remoteaccount => $remoteaccount}; } # load these only once $fnret = OVH::Bastion::get_plugin_list(restrictedOnly => 1); $fnret or osh_exit $fnret; my @commands = sort keys %{$fnret->value}; my @groups; if ($withGroups) { $fnret = OVH::Bastion::get_group_list(); $fnret or osh_exit $fnret; @groups = sort keys %{$fnret->value}; } # gather info from the account(s) my %return; foreach my $accHash (@accounts) { my %ret; my ($account, $sysaccount, $remoteaccount) = ($accHash->{'account'}, $accHash->{'sysaccount'}, $accHash->{'remoteaccount'}); $ret{'account'} = $account; if (OVH::Bastion::is_admin(account => $account)) { $ret{'is_admin'} = 1; } if (OVH::Bastion::is_super_owner(account => $account)) { $ret{'is_superowner'} = 1; } if (OVH::Bastion::is_auditor(account => $account)) { $ret{'is_auditor'} = 1; } my @granted; foreach my $plugin (@commands) { $fnret = OVH::Bastion::is_user_in_group(user => $account, group => "osh-$plugin"); push @granted, $plugin if $fnret; } $ret{'allowed_commands'} = \@granted; my $groups_hash = {}; if ($withGroups) { foreach my $name (@groups) { my @flags; push @flags, 'owner' if OVH::Bastion::is_group_owner(group => $name, account => $account); push @flags, 'gatekeeper' if OVH::Bastion::is_group_gatekeeper(group => $name, account => $account); push @flags, 'aclkeeper' if OVH::Bastion::is_group_aclkeeper(group => $name, account => $account); push @flags, 'member' if OVH::Bastion::is_group_member(group => $name, account => $account); push @flags, 'guest' if OVH::Bastion::is_group_guest(group => $name, account => $account); $groups_hash->{$name} = {flags => \@flags, name => $name} if @flags; } } $ret{'groups'} = $groups_hash; my $canConnect = 1; $ret{'always_active'} = OVH::Bastion::account_config( account => $account, key => OVH::Bastion::OPT_ACCOUNT_ALWAYS_ACTIVE, public => 1 ) ? 1 : 0; if ($ret{'always_active'}) { $ret{'is_active'} = 1; } else { $fnret = OVH::Bastion::is_account_active(account => $account); if ($fnret->is_ok) { $ret{'is_active'} = 1; } elsif ($fnret->is_ko) { $canConnect = 0; $ret{'is_active'} = 0; } } if (OVH::Bastion::is_auditor(account => $self)) { # TTL check $fnret = OVH::Bastion::is_account_ttl_nonexpired(sysaccount => $sysaccount, account => $account); if ($fnret->is_ok && $fnret->err eq 'OK_NO_TTL') { $ret{'is_ttl_set'} = 0; $ret{'is_ttl_expired'} = 0; } elsif ($fnret->is_ok && $fnret->err eq 'OK_TTL_VALID') { $ret{'is_ttl_set'} = 1; $ret{'is_ttl_expired'} = 0; } elsif ($fnret->is_ko) { $canConnect = 0; $ret{'is_ttl_set'} = 1; $ret{'is_ttl_expired'} = 1; } else { osh_warn "Error getting account TTL expiration info (" . $fnret->msg . ")"; } $ret{'ttl_timestamp'} = ($fnret->value && $fnret->value->{'expiry_time'}) ? $fnret->value->{'expiry_time'} : undef; # freeze check $fnret = OVH::Bastion::is_account_nonfrozen(account => $account); $ret{'is_frozen'} = undef; if ($fnret->is_ok) { $ret{'is_frozen'} = 0; } elsif ($fnret->is_ko) { $ret{'is_frozen'} = 1; $ret{'freeze_info'} = $fnret->value; $canConnect = 0; } # expi check $fnret = OVH::Bastion::is_account_nonexpired(sysaccount => $sysaccount, remoteaccount => $remoteaccount); if ($fnret->is_ok) { $ret{'is_expired'} = 0; } elsif ($fnret->is_ko) { $canConnect = 0; $ret{'is_expired'} = 1; } else { osh_warn "Error getting account expiration info (" . $fnret->msg . ")"; } if (!$fnret->is_err) { $ret{'can_connect'} = $canConnect; if ($fnret->value->{'already_seen_before'}) { $ret{'already_seen_before'} = 1; if (defined $fnret->value->{'seconds'}) { $fnret = OVH::Bastion::duration2human(seconds => $fnret->value->{'seconds'}, tense => "past"); if ($fnret) { $ret{'last_activity'}{$_} = $fnret->value->{$_} for qw{ datetime_local datetime_utc }; $ret{'last_activity'}{'ago'} = $fnret->value->{'duration'}; $ret{'last_activity'}{'timestamp'} = time() - $fnret->value->{'seconds'}; } } } else { $ret{'already_seen_before'} = 0; } } $fnret = OVH::Bastion::account_config(account => $account, key => "creation_info"); if ($fnret) { my $creation_info; eval { $creation_info = decode_json($fnret->value); }; if ($@) { osh_warn( "While reading creation metadata information for account '$account', couldn't decode JSON: $@"); } else { $ret{'creation_information'} = $creation_info; } } $fnret = OVH::Bastion::account_ssh_config_get(account => $account); if ($fnret->err eq 'OK_EMPTY') { $ret{'account_egress_ssh_config'}{'type'} = 'default'; } elsif ($fnret->err eq 'ERR_FILE_LOCALLY_MODIFIED') { $ret{'account_egress_ssh_config'}{'type'} = 'locally_modified'; } elsif ($fnret) { $ret{'account_egress_ssh_config'}{'type'} = 'custom'; foreach my $key (sort keys %{$fnret->value}) { $ret{'account_egress_ssh_config'}{'items'}{$key} = $fnret->value->{$key}; } } else { $ret{'account_egress_ssh_config'}{'type'} = 'unknown'; } $fnret = OVH::Bastion::account_config( account => $account, public => 1, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_POLICY ); $ret{'ingress_piv_enforced'} = ($fnret && $fnret->value eq 'yes') ? 1 : 0; # keep for backwards compat $ret{'ingress_piv_policy'} = $fnret->value || undef; $fnret = OVH::Bastion::account_config( account => $account, public => 1, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_GRACE ); if ($fnret && $fnret->value > time()) { my $expiry = $fnret->value - time(); my $human = OVH::Bastion::duration2human(seconds => $expiry)->value; $ret{'ingress_piv_grace'} = { enabled => 1, expiration_timestamp => $fnret->value, seconds_remaining => $expiry, expiration_date => $human->{'date'}, time_remaining => $human->{'duration'}, }; } else { $ret{'ingress_piv_grace'} = {enabled => 0}; } $ret{'global_ingress_policy'} = !!OVH::Bastion::config('ingressRequirePIV')->value + 0; $ret{'effective_ingress_piv_policy'} = !!OVH::Bastion::is_effective_piv_account_policy_enabled(account => $account)->is_ok + 0; $ret{'mfa_password_required'} = !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_REQUIRED_GROUP) + 0; $ret{'mfa_password_bypass'} = !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_BYPASS_GROUP) + 0; $ret{'mfa_password_configured'} = !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_CONFIGURED_GROUP) + 0; $ret{'mfa_totp_required'} = !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_REQUIRED_GROUP) + 0; $ret{'mfa_totp_bypass'} = !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_BYPASS_GROUP) + 0; $ret{'mfa_totp_configured'} = !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_CONFIGURED_GROUP) + 0; $ret{'pam_auth_bypass'} = !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::PAM_AUTH_BYPASS_GROUP) + 0; $ret{'pubkey_auth_optional'} = !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::OSH_PUBKEY_AUTH_OPTIONAL_GROUP) + 0; $ret{'personal_egress_mfa_required'} = OVH::Bastion::account_config(account => $account, key => "personal_egress_mfa_required")->value; $ret{'personal_egress_mfa_required'} ||= 'none'; # no config means no mfa $ret{'idle_ignore'} = !!OVH::Bastion::account_config( account => $account, key => OVH::Bastion::OPT_ACCOUNT_IDLE_IGNORE, public => 1 ) + 0; $ret{'max_inactive_days'} = OVH::Bastion::account_config(account => $account, %{OVH::Bastion::OPT_ACCOUNT_MAX_INACTIVE_DAYS()})->value; if ($withPasswordInfo) { my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountGetPasswordInfo'; push @command, '--account', $account; $fnret = OVH::Bastion::helper(cmd => \@command); if ($fnret) { $ret{'password'}{$_} = $fnret->value->{$_} for (keys %{$fnret->value}); } } } $return{$account} = \%ret; # print all this in a human-readable format, except if we've been asked # to dump the data for all accounts, in which case the caller will only use # our JSON output print_account_info(%ret) if !$all; } sub print_account_info { my %ret = @_; my $acc = $ret{'account'}; osh_info("$acc is a bastion " . colored('admin', 'green')) if $ret{'is_admin'}; osh_info("$account is a bastion " . colored('superowner', 'green')) if $ret{'is_superowner'}; osh_info("$account is a bastion " . colored('auditor', 'green')) if $ret{'is_auditor'}; osh_info "This account has access to the following restricted commands:"; osh_info("- $_") for @{$ret{'allowed_commands'}}; osh_info "(none)" if (!@{$ret{'allowed_commands'}}); if ($withGroups) { osh_info("\nThis account is part of the following groups:"); foreach my $groupName (sort keys %{$ret{'groups'}}) { my @flags = @{$ret{'groups'}{$groupName}{'flags'}}; my $line = sprintf "%18s", $groupName; $line .= sprintf " %14s", colored(grep({ $_ eq 'owner' } @flags) ? 'Owner' : '-', 'red'); $line .= sprintf " %19s", colored(grep({ $_ eq 'gatekeeper' } @flags) ? 'GateKeeper' : '-', 'yellow'); $line .= sprintf " %18s", colored(grep({ $_ eq 'aclkeeper' } @flags) ? 'ACLKeeper' : '-', 'magenta'); $line .= sprintf " %15s", colored(grep({ $_ eq 'member' } @flags) ? 'Member' : '-', 'green'); $line .= sprintf " %14s", colored(grep({ $_ eq 'guest' } @flags) ? 'Guest' : '-', 'cyan'); osh_info($line); } osh_info("(none)") if not %{$ret{'groups'}}; osh_info("\n"); } if ($ret{'always_active'}) { osh_info("This account is " . colored('always', 'green') . " active"); } elsif ($ret{'is_active'}) { osh_info("This account is " . colored('active', 'green')); } else { osh_info "\nThis account is " . colored('INACTIVE', 'red'); } if (defined $ret{'is_ttl_set'}) { my $human = '?'; if (!$ret{'is_ttl_set'}) { osh_info("This account has " . colored('no TTL set', 'green')); } elsif (!$ret{'is_ttl_expired'}) { $fnret = OVH::Bastion::duration2human(seconds => $ret{'ttl_timestamp'} - time()); $human = $fnret->value->{'human'}; osh_info "This account " . colored('TTL is still valid', 'green') . " (for $human)"; } else { $fnret = OVH::Bastion::duration2human(seconds => time() - $ret{'ttl_timestamp'}, tense => "past"); $human = $fnret->value->{'human'}; osh_info "This account " . colored('TTL is EXPIRED', 'red') . " (since $human)"; } } if (defined $ret{'is_frozen'}) { if (!$ret{'is_frozen'}) { osh_info "This account is " . colored('not frozen', 'green'); } else { my $freezeReason = $ret{'freeze_info'}{'reason'} || 'no reason given'; my $freezeBy = $ret{'freeze_info'}{'by'} || '(unknown)'; osh_info "This account has been " . colored('FROZEN', 'red') . " by $freezeBy ($freezeReason)"; } } if (defined $ret{'is_expired'}) { if ($ret{'is_expired'}) { osh_info "This account is " . colored('EXPIRED', 'red') . " activity-wise (it hasn't been seen for a long time)"; } else { osh_info "This account has seen recent-enough activity to " . colored('not be activity-expired', 'green'); } } if (defined $ret{'can_connect'}) { osh_info "As a consequence, this account " . ($ret{'can_connect'} ? colored("can", 'green') : colored("CANNOT", 'red')) . " connect to this bastion\n\n"; } if (defined $ret{'already_seen_before'}) { if ($ret{'already_seen_before'}) { if ($ret{'last_activity'}) { my $seenBeforeStr = $ret{'last_activity'}{'datetime_utc'}; if ( $ret{'last_activity'}{'datetime_local'} && $ret{'last_activity'}{'datetime_utc'} ne $ret{'last_activity'}{'datetime_local'}) { $seenBeforeStr .= " / " . $ret{'last_activity'}{'datetime_local'}; } $seenBeforeStr = sprintf("Last seen on %s (%s ago)", colored($seenBeforeStr, 'magenta'), $ret{'last_activity'}{'ago'},); osh_info($seenBeforeStr); } else { osh_info("This account has already been used at least once"); } } else { osh_info("This account has " . colored('NEVER', 'red') . " been used (yet)"); } } if ($ret{'creation_information'}) { if ($ret{'creation_information'}{'datetime_utc'}) { my $createdOnStr = $ret{'creation_information'}{'datetime_utc'}; if ( $ret{'creation_information'}{'datetime_local'} && $ret{'creation_information'}{'datetime_utc'} ne $ret{'creation_information'}{'datetime_local'}) { $createdOnStr .= " / " . $ret{'creation_information'}{'datetime_local'}; } $createdOnStr = sprintf( "Created on %s (%s ago)", colored($createdOnStr, 'magenta'), OVH::Bastion::duration2human(seconds => time() - $ret{'creation_information'}{'timestamp'}) ->value->{'duration'} ); osh_info($createdOnStr); } if ($ret{'creation_information'}{'by'}) { osh_info("Created by " . colored($ret{'creation_information'}{'by'}, 'magenta')); } if ($ret{'creation_information'}{'bastion_version'}) { osh_info("Created using The Bastion " . colored('v' . $ret{'creation_information'}{'bastion_version'}, 'magenta')); } if ($ret{'creation_information'}{'comment'}) { osh_info( "Creation with the following comment: " . colored($ret{'creation_information'}{'comment'}, 'magenta')); } } osh_info "\nAccount egress SSH config:"; if ($ret{'account_egress_ssh_config'}{'type'} eq 'default') { osh_info "- (default)"; } elsif ($ret{'account_egress_ssh_config'}{'type'} eq 'locally_modified') { osh_info "- (locally modified!)"; } elsif ($ret{'account_egress_ssh_config'}{'type'} eq 'custom') { foreach my $key (sort keys %{$ret{'account_egress_ssh_config'}{'items'} || {}}) { osh_info "- $key " . $ret{'account_egress_ssh_config'}{'items'}{$key}; } } else { osh_info "- (unknown)"; } osh_info "\nAccount PIV-only policy status:"; my $ingress_piv_policy_print = $ret{'ingress_piv_policy'} || 'default'; osh_info "- PIV policy for ingress keys on this account is set to " . colored($ingress_piv_policy_print, $ingress_piv_policy_print eq 'default' ? 'blue' : 'green'); if ($ret{'ingress_piv_grace'} && $ret{'ingress_piv_grace'}{'seconds_remaining'}) { $fnret = OVH::Bastion::duration2human(seconds => $ret{'ingress_piv_grace'}{'seconds_remaining'})->value; osh_info("- PIV grace period for this account is " . colored('set', 'green') . " and expires in " . $fnret->value->{'human'}); } else { osh_info "- PIV grace period for this account is " . colored('inactive', 'blue'); } osh_info "- Global PIV policy status is " . ($ret{'global_ingress_policy'} ? colored('enabled', 'red') : colored('disabled', 'blue')); osh_info "- As a consequence, PIV policy is " . ($ret{'effective_ingress_piv_policy'} ? colored('enforced', 'red') : colored('inactive', 'blue')) . " for this account"; osh_info "\nAccount Multi-Factor Authentication status:"; osh_info "- Additional password authentication is " . ($ret{'mfa_password_required'} ? colored('required', 'green') : colored('not required', 'blue')) . " for this account"; osh_info "- Additional password authentication bypass is " . ($ret{'mfa_password_bypass'} ? colored('enabled', 'green') : colored('disabled', 'blue')) . " for this account"; osh_info "- Additional password authentication is " . ($ret{'mfa_password_configured'} ? colored('enabled and active', 'green') : colored('disabled', 'blue')); osh_info "- Additional TOTP authentication is " . ($ret{'mfa_totp_required'} ? colored('required', 'green') : colored('not required', 'blue')) . " for this account"; osh_info "- Additional TOTP authentication bypass is " . ($ret{'mfa_totp_bypass'} ? colored('enabled', 'green') : colored('disabled', 'blue')) . " for this account"; osh_info "- Additional TOTP authentication is " . ($ret{'mfa_totp_configured'} ? colored('enabled and active', 'green') : colored('disabled', 'blue')); osh_info "- PAM authentication bypass is " . ($ret{'pam_auth_bypass'} ? colored('enabled', 'green') : colored('disabled', 'blue')); osh_info "- Optional public key authentication is " . ($ret{'pubkey_auth_optional'} ? colored('enabled', 'green') : colored('disabled', 'blue')); osh_info "- MFA policy on personal accesses (using personal keys) on egress side is: " . $ret{'personal_egress_mfa_required'}; osh_info "\n- Account is immune to idle counter-measures: " . ($ret{'idle_ignore'} ? colored('yes', 'green') : colored('no', 'blue')); if (!defined $ret{'max_inactive_days'}) { osh_info "- Maximum number of days of inactivity before account is disabled: (default)"; } elsif ($ret{'max_inactive_days'} == 0) { osh_info "- Maximum number of days of inactivity before account is disabled: never"; } else { osh_info "- Maximum number of days of inactivity before account is disabled: " . $ret{'max_inactive_days'}; } if ($ret{'password'}) { osh_info "Account PAM UNIX password information (used for password MFA):"; if ($ret{'password'}{'password'} eq 'locked') { osh_info "- No valid password is set"; } else { osh_info "- Password is " . $ret{'password'}{'password'}; } osh_info "- Password was last changed on " . $ret{'password'}{'date_changed'}; if ($ret{'password'}{'max_days'} == -1) { osh_info "- Password will never expire"; } else { osh_info "- Password must be changed every " . $ret{'password'}{'max_days'} . " days at least"; osh_info "- A warning is displayed " . $ret{'password'}{'warn_days'} . " days before expiration"; } if ($ret{'password'}{'min_days'} != 0) { osh_info "- The minimum time between two password changes is " . $ret{'password'}{'min_days'} . " days"; } if ($ret{'password'}{'max_days'} != -1) { if ($ret{'password'}{'inactive_days'} != -1) { osh_info "- Account will be disabled " . $ret{'password'}{'inactive_days'} . " days after password expiration"; } else { osh_info "- Account will not be disabled after password expiration"; } } } return; } if (!$all) { # only one account, don't return a hash of hash to keep backward compat my @keys = keys %return; osh_ok $return{$keys[0]}; } else { osh_info "If you're only seeing this line, you might want to use --json"; osh_ok \%return; }