#!/bin/bash # Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one # or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at # https://securityonion.net/license; you may not use this file except in compliance with the # Elastic License 2.0. source $(dirname $0)/so-common DEFAULT_ROLE=analyst function usage() { cat < [supporting parameters] where is one of the following: list: Lists all user email addresses currently defined in the identity system add: Adds a new user to the identity system Required parameters: --email Optional parameters: --role (defaults to $DEFAULT_ROLE) --firstName (defaults to blank) --lastName (defaults to blank) --note (defaults to blank) --skip-sync (defers the Elastic sync until the next scheduled time) addrole: Grants a role to an existing user Required parameters: --email --role Optional parameters: --skip-sync (defers the Elastic sync until the next scheduled time) delrole: Removes a role from an existing user Required parameters: --email --role Optional parameters: --skip-sync (defers the Elastic sync until the next scheduled time) password: Updates a user's password and disables MFA Required parameters: --email Optional parameters: --skip-sync (defers the Elastic sync until the next scheduled time) profile: Updates a user's profile information Required parameters: --email Optional parameters: --role (defaults to $DEFAULT_ROLE) --firstName (defaults to blank) --lastName (defaults to blank) --note (defaults to blank) enable: Enables a user Required parameters: --email Optional parameters: --skip-sync (defers the Elastic sync until the next scheduled time) disable: Disables a user Required parameters: --email Optional parameters: --skip-sync (defers the Elastic sync until the next scheduled time) validate: Validates that the given email address and password are acceptable Required parameters: --email valemail: Validates that the given email address is acceptable; requires 'email' parameter Required parameters: --email valpass: Validates that a password is acceptable Note that the password can be piped into STDIN to avoid prompting for it USAGE_EOF exit 1 } if [[ $# -lt 1 || $1 == --help || $1 == -h || $1 == -? || $1 == --h ]]; then usage fi operation=$1 shift while [[ $# -gt 0 ]]; do param=$1 shift case "$param" in --email) email=$1 shift ;; --role) role=$1 shift ;; --firstName) firstName=$1 shift ;; --lastName) lastName=$1 shift ;; --note) note=$1 shift ;; --skip-sync) SKIP_SYNC=1 ;; *) echo "Encountered unexpected parameter: $param" usage ;; esac done kratosUrl=${KRATOS_URL:-http://127.0.0.1:4434/admin} databasePath=${KRATOS_DB_PATH:-/nsm/kratos/db/db.sqlite} databaseTimeout=${KRATOS_DB_TIMEOUT:-5000} bcryptRounds=${BCRYPT_ROUNDS:-12} elasticUsersFile=${ELASTIC_USERS_FILE:-/opt/so/saltstack/local/salt/elasticsearch/files/users} elasticRolesFile=${ELASTIC_ROLES_FILE:-/opt/so/saltstack/local/salt/elasticsearch/files/users_roles} socRolesFile=${SOC_ROLES_FILE:-/opt/so/conf/soc/soc_users_roles} esUID=${ELASTIC_UID:-930} esGID=${ELASTIC_GID:-930} soUID=${SOCORE_UID:-939} soGID=${SOCORE_GID:-939} function lock() { # Obtain file descriptor lock exec 99>/var/tmp/so-user.lock || fail "Unable to create lock descriptor; if the system was not shutdown gracefully you may need to remove /var/tmp/so-user.lock manually." flock -w 10 99 || fail "Another process is using so-user; if the system was not shutdown gracefully you may need to remove /var/tmp/so-user.lock manually." trap 'rm -f /var/tmp/so-user.lock' EXIT } function fail() { msg=$1 echo "$1" exit 1 } function require() { cmd=$1 which "$1" 2>&1 > /dev/null [[ $? != 0 ]] && fail "This script requires the following command be installed: ${cmd}" } # Verify this environment is capable of running this script function verifyEnvironment() { require "htpasswd" require "jq" require "curl" require "openssl" require "sqlite3" [[ ! -f $databasePath ]] && fail "Unable to find database file; specify path via KRATOS_DB_PATH environment variable" response=$(curl -Ss -L ${kratosUrl}/) [[ "$response" != "404 page not found" ]] && fail "Unable to communicate with Kratos; specify URL via KRATOS_URL environment variable" } function findIdByEmail() { email=$1 response=$(curl -Ss -L ${kratosUrl}/identities) identityId=$(echo "${response}" | jq -r ".[] | select(.verifiable_addresses[0].value == \"$email\") | .id") echo $identityId } function validatePassword() { password=$1 len=$(expr length "$password") if [[ $len -lt 8 ]]; then fail "Password does not meet the minimum requirements" fi if [[ $len -gt 72 ]]; then fail "Password is too long (max: 72)" fi check_password_and_exit "$password" } function validateEmail() { email=$1 # (?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\]) if [[ ! "$email" =~ ^[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}$ ]]; then fail "Email address is invalid" fi if [[ "$email" =~ [A-Z] ]]; then fail "Email addresses cannot contain uppercase letters" fi } function hashPassword() { password=$1 passwordHash=$(echo "${password}" | htpasswd -niBC $bcryptRounds SOUSER) passwordHash=$(echo "$passwordHash" | cut -c 11-) passwordHash="\$2a${passwordHash}" # still waiting for https://github.com/elastic/elasticsearch/issues/51132 echo "$passwordHash" } function updatePassword() { identityId=$1 if [ -z "$password" ]; then # Read password from stdin (show prompt only if no stdin was piped in) test -t 0 if [[ $? == 0 ]]; then echo "Enter new password:" fi read -rs password validatePassword "$password" fi if [[ -n "$identityId" ]]; then # Generate password hash passwordHash=$(hashPassword "$password") # Update DB with new hash echo "update identity_credentials set config=CAST('{\"hashed_password\":\"$passwordHash\"}' as BLOB), created_at=datetime('now'), updated_at=datetime('now') where identity_id='${identityId}' and identity_credential_type_id=(select id from identity_credential_types where name='password');" | sqlite3 -cmd ".timeout ${databaseTimeout}" "$databasePath" # Deactivate MFA echo "delete from identity_credential_identifiers where identity_credential_id=(select id from identity_credentials where identity_id='${identityId}' and identity_credential_type_id=(select id from identity_credential_types where name='totp'));" | sqlite3 -cmd ".timeout ${databaseTimeout}" "$databasePath" echo "delete from identity_credentials where identity_id='${identityId}' and identity_credential_type_id=(select id from identity_credential_types where name='totp');" | sqlite3 -cmd ".timeout ${databaseTimeout}" "$databasePath" [[ $? != 0 ]] && fail "Unable to update password" fi } function createFile() { filename=$1 uid=$2 gid=$3 mkdir -p $(dirname "$filename") truncate -s 0 "$filename" chmod 600 "$filename" chown "${uid}:${gid}" "$filename" } function ensureRoleFileExists() { if [[ ! -f "$socRolesFile" || ! -s "$socRolesFile" ]]; then # Generate the new users file rolesTmpFile="${socRolesFile}.tmp" createFile "$rolesTmpFile" "$soUID" "$soGID" if [[ -f "$databasePath" ]]; then echo "Migrating roles to new file: $socRolesFile" echo "select 'superuser:' || id from identities;" | sqlite3 -cmd ".timeout ${databaseTimeout}" "$databasePath" \ >> "$rolesTmpFile" [[ $? != 0 ]] && fail "Unable to read identities from database" echo "The following users have all been migrated with the super user role:" cat "${rolesTmpFile}" else echo "Database file does not exist yet, installation is likely not yet complete." fi if [[ -d "$socRolesFile" ]]; then echo "Removing invalid roles directory created by Docker" rm -fr "$socRolesFile" fi mv "${rolesTmpFile}" "${socRolesFile}" fi } function syncElasticSystemUser() { json=$1 userid=$2 usersFile=$3 user=$(echo "$json" | jq -r ".local.users.$userid.user") pass=$(echo "$json" | jq -r ".local.users.$userid.pass") [[ -z "$user" || -z "$pass" ]] && fail "Elastic auth credentials for system user '$userid' are missing" hash=$(hashPassword "$pass") echo "${user}:${hash}" >> "$usersFile" } function syncElasticSystemRole() { json=$1 userid=$2 role=$3 rolesFile=$4 user=$(echo "$json" | jq -r ".local.users.$userid.user") [[ -z "$user" ]] && fail "Elastic auth credentials for system user '$userid' are missing" echo "${role}:${user}" >> "$rolesFile" } function syncElastic() { [[ -n $SKIP_SYNC ]] && return echo "Syncing users and roles between SOC and Elastic..." usersTmpFile="${elasticUsersFile}.tmp" createFile "${usersTmpFile}" "$esUID" "$esGID" rolesTmpFile="${elasticRolesFile}.tmp" createFile "${rolesTmpFile}" "$esUID" "$esGID" authPillarJson=$(lookup_salt_value "auth" "elasticsearch" "pillar" "json") syncElasticSystemUser "$authPillarJson" "so_elastic_user" "$usersTmpFile" syncElasticSystemUser "$authPillarJson" "so_kibana_user" "$usersTmpFile" syncElasticSystemUser "$authPillarJson" "so_logstash_user" "$usersTmpFile" syncElasticSystemUser "$authPillarJson" "so_beats_user" "$usersTmpFile" syncElasticSystemUser "$authPillarJson" "so_monitor_user" "$usersTmpFile" syncElasticSystemRole "$authPillarJson" "so_elastic_user" "superuser" "$rolesTmpFile" syncElasticSystemRole "$authPillarJson" "so_kibana_user" "kibana_system" "$rolesTmpFile" syncElasticSystemRole "$authPillarJson" "so_logstash_user" "superuser" "$rolesTmpFile" syncElasticSystemRole "$authPillarJson" "so_beats_user" "superuser" "$rolesTmpFile" syncElasticSystemRole "$authPillarJson" "so_monitor_user" "remote_monitoring_collector" "$rolesTmpFile" syncElasticSystemRole "$authPillarJson" "so_monitor_user" "remote_monitoring_agent" "$rolesTmpFile" syncElasticSystemRole "$authPillarJson" "so_monitor_user" "monitoring_user" "$rolesTmpFile" if [[ -f "$databasePath" && -f "$socRolesFile" ]]; then # Append the SOC users userData=$(echo "select '{\"user\":\"' || ici.identifier || '\", \"data\":' || ic.config || '}'" \ "from identity_credential_identifiers ici, identity_credentials ic, identities i, identity_credential_types ict " \ "where " \ " ici.identity_credential_id=ic.id " \ " and ic.identity_id=i.id " \ " and ict.id=ic.identity_credential_type_id " \ " and ict.name='password' " \ " and instr(ic.config, 'hashed_password') " \ " and i.state == 'active' " \ "order by ici.identifier;" | \ sqlite3 -cmd ".timeout ${databaseTimeout}" "$databasePath") [[ $? != 0 ]] && fail "Unable to read credential hashes from database" echo "${userData}" | \ jq -r '.user + ":" + .data.hashed_password' \ >> "$usersTmpFile" # Append the user roles while IFS="" read -r rolePair || [ -n "$rolePair" ]; do userId=$(echo "$rolePair" | cut -d: -f2) role=$(echo "$rolePair" | cut -d: -f1) echo "select '$role:' || ici.identifier " \ "from identity_credential_identifiers ici, identity_credentials ic, identity_credential_types ict " \ "where ici.identity_credential_id=ic.id " \ " and ict.id=ic.identity_credential_type_id " \ " and ict.name='password' " \ " and ic.identity_id = '$userId';" | \ sqlite3 -cmd ".timeout ${databaseTimeout}" "$databasePath" >> "$rolesTmpFile" [[ $? != 0 ]] && fail "Unable to read role identities from database" done < "$socRolesFile" else echo "Database file or soc roles file does not exist yet, skipping users export" fi if [[ -s "${usersTmpFile}" ]]; then mv "${usersTmpFile}" "${elasticUsersFile}" mv "${rolesTmpFile}" "${elasticRolesFile}" if [[ -z "$SKIP_STATE_APPLY" ]]; then echo "Elastic state will be re-applied to affected minions. This will run in the background and may take several minutes to complete." echo "Applying elastic state to elastic minions at $(date)" >> /opt/so/log/soc/sync.log 2>&1 salt --async -C 'G@role:so-standalone or G@role:so-eval or G@role:so-import or G@role:so-manager or G@role:so-managersearch or G@role:so-searchnode or G@role:so-heavynode' state.apply elasticsearch queue=True >> /opt/so/log/soc/sync.log 2>&1 fi else echo "Newly generated users/roles files are incomplete; aborting." fi } function syncAll() { ensureRoleFileExists # Check if a sync is needed. Sync is not needed if the following are true: # - user database entries are all older than the elastic users file # - soc roles file last modify date is older than the elastic roles file if [[ -z "$FORCE_SYNC" && -f "$databasePath" && -f "$elasticUsersFile" ]]; then usersFileAgeSecs=$(echo $(($(date +%s) - $(date +%s -r "$elasticUsersFile")))) staleCount=$(echo "select count(*) from identity_credentials where updated_at >= Datetime('now', '-${usersFileAgeSecs} seconds');" \ | sqlite3 -cmd ".timeout ${databaseTimeout}" "$databasePath") [[ $? != 0 ]] && fail "Unable to read user count from database" if [[ "$staleCount" == "0" && "$elasticRolesFile" -nt "$socRolesFile" ]]; then return 1 fi fi syncElastic return 0 } function listUsers() { response=$(curl -Ss -L ${kratosUrl}/identities) [[ $? != 0 ]] && fail "Unable to communicate with Kratos" users=$(echo "${response}" | jq -r ".[] | .verifiable_addresses[0].value" | sort) for user in $users; do roles=$(grep ":$user\$" "$elasticRolesFile" | cut -d: -f1 | tr '\n' ' ') echo "$user: $roles" done } function addUserRole() { email=$1 role=$2 adjustUserRole "$email" "$role" "add" } function deleteUserRole() { email=$1 role=$2 adjustUserRole "$email" "$role" "del" } function adjustUserRole() { email=$1 role=$2 op=$3 identityId=$(findIdByEmail "$email") [[ ${identityId} == "" ]] && fail "User not found" ensureRoleFileExists filename="$socRolesFile" hasRole=0 grep "^$role:" "$socRolesFile" | grep -q "$identityId" && hasRole=1 if [[ "$op" == "add" ]]; then if [[ "$hasRole" == "1" ]]; then echo "User '$email' already has the role: $role" return 1 else echo "$role:$identityId" >> "$filename" fi elif [[ "$op" == "del" ]]; then if [[ "$hasRole" -ne 1 ]]; then fail "User '$email' does not have the role: $role" else sed "/^$role:$identityId\$/d" "$filename" > "$filename.tmp" cat "$filename".tmp > "$filename" rm -f "$filename".tmp fi else fail "Unsupported role adjustment operation: $op" fi return 0 } function createUser() { email=$1 role=$2 firstName=$3 lastName=$4 note=$5 now=$(date -u +%FT%TZ) addUserJson=$(cat < "$rolesTmpFile" cat "$rolesTmpFile" > "$socRolesFile" } case "${operation}" in "add") verifyEnvironment [[ "$email" == "" ]] && fail "Email address must be provided" lock validateEmail "$email" updatePassword createUser "$email" "${role:-$DEFAULT_ROLE}" "${firstName}" "${lastName}" "${note}" syncAll echo "Successfully added new user to SOC" echo "$password" | so-influxdb-manage useradd "$email" if [[ "$role" == "superuser" ]]; then echo "$password" | so-influxdb-manage userpromote "$email" fi ;; "list") verifyEnvironment listUsers ;; "addrole") verifyEnvironment [[ "$email" == "" ]] && fail "Email address must be provided" [[ "$role" == "" ]] && fail "Role must be provided" lock validateEmail "$email" if addUserRole "$email" "$role"; then syncElastic echo "Successfully added role to user" if [[ "$role" == "superuser" ]]; then echo "$password" | so-influxdb-manage userpromote "$email" fi fi ;; "delrole") verifyEnvironment [[ "$email" == "" ]] && fail "Email address must be provided" [[ "$role" == "" ]] && fail "Role must be provided" lock validateEmail "$email" deleteUserRole "$email" "$role" syncElastic echo "Successfully removed role from user" if [[ "$role" == "superuser" ]]; then echo "$password" | so-influxdb-manage userdemote "$email" fi ;; "password") verifyEnvironment [[ "$email" == "" ]] && fail "Email address must be provided" lock updateUserPassword "$email" syncAll echo "Successfully updated user password" echo "$password" | so-influxdb-manage userpass "$email" ;; "profile") verifyEnvironment [[ "$email" == "" ]] && fail "Email address must be provided" lock updateUserProfile "$email" echo "Successfully updated user profile" ;; "enable") verifyEnvironment [[ "$email" == "" ]] && fail "Email address must be provided" lock updateStatus "$email" 'active' syncAll echo "Successfully enabled user" so-influxdb-manage userenable "$email" ;; "disable") verifyEnvironment [[ "$email" == "" ]] && fail "Email address must be provided" lock updateStatus "$email" 'locked' syncAll echo "Successfully disabled user" so-influxdb-manage userdisable "$email" ;; "delete") verifyEnvironment [[ "$email" == "" ]] && fail "Email address must be provided" lock deleteUser "$email" syncAll echo "Successfully deleted user" so-influxdb-manage userdel "$email" ;; "sync") lock syncAll ;; "validate") validateEmail "$email" updatePassword echo "Email and password are acceptable" ;; "valemail") validateEmail "$email" echo "Email is acceptable" ;; "valpass") updatePassword echo "Password is acceptable" ;; *) fail "Unsupported operation: $operation" usage ;; esac exit 0