Jeremy Swinarton
Jeremy Swinarton

Reputation: 537

sh: variable value access by string name

I have written a short script that checks for the presence of an environment variable, and fails if it does not exist. The purpose of this script is for use on a CI server, which has configuration variables loaded through a console. I want to ensure that these variables have been set, and fail the job upfront if they haven't.

The execution environment for these jobs is a Docker container based on Alpine Linux. I only have access to sh. I would like to avoid installation of another shell like bash to keep the image size as slim as possible.

The script looks roughly like this:

#!/bin/sh

AWS_ACCESS_KEY_ID=123  # provided by CI

 _fail_without() {
  VAR_NAME=$1
  VAR_VAL=$(eval echo "\$$VAR_NAME")

   if [[ -z "${VAR_VAL}" ]]; then
    echo "${VAR_NAME} not set; aborting"
    exit 1
  else
    echo "${VAR_NAME} exists"
  fi
}

_fail_without AWS_ACCESS_KEY_ID
_fail_without AWS_SECRET_ACCESS_KEY

... with the expected stdout:

AWS_ACCESS_KEY_ID exists
AWS_SECRET_ACCESS_KEY not set; aborting

As you can see, I have passed the string value of the variable name, rather than the variable itself, so that failures will be properly logged. All of this works fine. However, I'm concerned about the potential security implications of relying on eval to access the variable value in the line VAR_VAL=$(eval echo "\$$VAR_NAME").

The question is: is this a viable approach, are there any security implications to be aware of, and if there is, is there a more secure or better alternative? I can't use declare, and printf doesn't seem to behave the same way it does in bash either.

Upvotes: 1

Views: 899

Answers (1)

KamilCuk
KamilCuk

Reputation: 141613

After some dwelling, reading posix shell manual and finding any good in posix utilities I finally settled I would use variable expansions ${var:?} and ${var?} to check if a variable is set or unset, null or not null and that I will use expr utility with BRE posix regex to check if a variable is a valid variable name.

Below are the functions that I have ended up with. A small test function and some test cases are on the end. I feel like the expr BRE matching is the most not-portable part of it all, however I couldn't find any false positives in var_is_name.

#!/bin/sh

# var ####################################################################################################

#
# Check if arguments are a valid "name" identifier in the POSIX shell contects
# @args identifiers
# @returns
# 0 - all identifiers are valid names
# 1 - any one of identifiers is not a valid name
# 2 - internal error
# 3 - even worse internal error
var_is_name() {
    # 3.230 Name
    # In the shell command language, a word consisting solely of underscores, digits, and alphabetics from the portable character set. The first character of a name is not a digit.
    local _var_is_name_i
    for _var_is_name_i; do
        expr "$_var_is_name_i" : '[_a-zA-Z][_a-zA-Z0-9]*$' >/dev/null || return $?
    done
}

# @args identifiers
# @returns Same as var_is_name but returns `2` in case if any of the arguments is not a valid name
var_is_name_error_on_fail() {
    local _var_is_name_error_on_fail_ret
    var_is_name "$@" && _var_is_name_error_on_fail_ret=$? || _var_is_name_error_on_fail_ret=$?
    if [ "$_var_is_name_error_on_fail_ret" -eq 1 ]; then return 2
    elif [ "$_var_is_name_error_on_fail_ret" -ne 0 ]; then return "$_var_is_name_error_on_fail_ret"
    fi
}

# @args identifiers
# @returns 
# 0 - if all identifiers are set
# 1 - if any of the identifiers is not set
# other - in case of error (ex. any of the identifiers is not a valid name)
var_is_set() {
    var_is_name_error_on_fail "$@" || return $?
    local _var_is_set_i
    for _var_is_set_i; do
        if ! ( eval printf %.0s "\"\${$_var_is_set_i?}\"" ) 2>/dev/null; then
            return 1
        fi
    done
    return 0
}

# @args identifiers
# @returns 
# 0 - if all identifiers are null
# 1 - if any of the identifiers is not null
# other - in case of error (ex. any of the identifiers is not a valid name)
var_is_null() {
    var_is_name_error_on_fail "$@" || return $?
    var_is_set "$@" || return $?
    local _var_is_null_i
    for _var_is_null_i; do
        ( eval printf %.0s "\"\${$_var_is_null_i:?}\"" ) 2>/dev/null || return 0
    done
    return 1
}

# @args identifiers
# @returns 
# 0 - if all identifiers are not null
# 1 - if any of the identifiers is null
# other - in case of error (ex. any of the identifiers is not a valid name)
var_is_not_null() {
    var_is_name_error_on_fail "$@" || return $?
    var_is_set "$@" || return $?
    local _var_is_not_null_ret
    var_is_null "$@" && _var_is_not_null_ret=$? || _var_is_not_null_ret=$?
    if [ "$_var_is_not_null_ret" -eq 0 ]; then
        return 1
    elif [ "$_var_is_not_null_ret" -eq 1 ]; then
        return 0;
    fi
    return "$_var_is_not_null_ret"
}

#################################################################################################################

var_test() {
    local ret

    var_is_name "$@" && ret=$? || ret=$?
    if [ "$ret" -eq 0 ]; then printf "%s is %s\n" "$1" "name"
    elif [ "$ret" -eq 1 ]; then printf "%s is not %s\n" "$1" "name"
    else printf "err var_is_name %s %s\n" "$1" "$ret"; fi

    var_is_set "$@" && ret=$? || ret=$?
    if [ "$ret" -eq 0 ]; then printf "%s is %s\n" "$1" "set"
    elif [ "$ret" -eq 1 ]; then printf "%s is not %s\n" "$1" "set"
    elif [ "$ret" -eq 2 ]; then printf "var_is_set %s errored\n" "$1"
    else printf "err var_is_set %s %s\n" "$1" "$ret"; fi

    var_is_null "$@" && ret=$? || ret=$?
    if [ "$ret" -eq 0 ]; then printf "%s is %s\n" "$1" "null"
    elif [ "$ret" -eq 1 ]; then printf "%s is not %s\n" "$1" "null"
    elif [ "$ret" -eq 2 ]; then printf "var_is_null %s errored\n" "$1"
    else printf "err var_is_null %s %s\n" "$1" "$ret"; fi

    var_is_not_null "$@" && ret=$? || ret=$?
    if [ "$ret" -eq 0 ]; then printf "%s is %s\n" "$1" "not_null"
    elif [ "$ret" -eq 1 ]; then printf "%s is not %s\n" "$1" "not_null"
    elif [ "$ret" -eq 2 ]; then printf "var_is_not_null %s errored\n" "$1"
    else printf "err var_is_not_null %s %s\n" "$1" "$ret"; fi

    echo
}

var_test '$()'
var_test '$()def'
var_test 'abc$()'
var_test 'abc$()def'
echo "unset a"; var_test a
a=; echo "a=$a"; var_test a
a=""; echo "a=\"\""; var_test a
a='$(echo I will format your harddrive >&2)'; echo "a='$a'"; var_test a
a='!@$%^&*(){}:"|<>>?~'\'; echo "a='$a'"; var_test a

When run inside alpine the script will output:

# the script saved in /tmp/script.sh
$ chmod +x /tmp/script.sh
$ docker run --rm -ti -v /tmp:/mnt alpine /mnt/script.sh
$() is not name
var_is_set $() errored
var_is_null $() errored
var_is_not_null $() errored

$()def is not name
var_is_set $()def errored
var_is_null $()def errored
var_is_not_null $()def errored

abc$() is not name
var_is_set abc$() errored
var_is_null abc$() errored
var_is_not_null abc$() errored

abc$()def is not name
var_is_set abc$()def errored
var_is_null abc$()def errored
var_is_not_null abc$()def errored

unset a
a is name
a is not set
a is not null
a is not not_null

a=
a is name
a is set
a is null
a is not not_null

a=""
a is name
a is set
a is null
a is not not_null

a='$(echo I will format your harddrive >&2)'
a is name
a is set
a is not null
a is not_null

a='!@$%^&*(){}:"|<>>?~''
a is name
a is set
a is not null
a is not_null

That said, I think this is too much hassle for a simple "is a variable set or not" checking. Sometimes I just trust other they will not do a strange things, and if they do, it will break theirs computer not mine. So sometimes I would advise to just settle for simple solutions like yours - [ -n "$(eval echo "\"\${$var}\"")" ] && echo "$var is set" || echo "$var is not set is sometimes just enough when you trust your inputs.

Upvotes: 1

Related Questions