Safe Bash Script Template

丁宏浚
2023-12-01

Safe Bash Script Template

This is a note on this blog regarding a basic template to create secure and functional bash scripts. The author is Maciej with github and linkedin][(https://www.linkedin.com/in/mradzikowski/).

As I have no previous background for bash script programming, this may serve as a friendly entry level introduction for others with zero experience.

Refer to bash documentation for reference.

Bash script

Bash is a unix shell and command language replacing Bourne shell. It is the default login shell for nowt linux distributions. It is a command processor where the users type commands to trigger actions, or read and execute commands from a file, called a shell script.

Looks like bash script and shell script are largely the same thing: bash is one of the most popular shell.

It’s name is a pun of ‘Burne Again SHell’

Example task

To help us go through the template let’s create an example script to which we migrate the template.

cd ./files
cat input_file.txt > output_file.txt
echo "Copied from input file." >> output_file.txt

With this file and the input/output files created, try

chmod +x example.sh
./example.sh

Choose Bash

#!/usr/bin/env bash

Often times there are four shells installed on a linux distro:

  • Bourne shell (sh) at /bin/sh or /sbin/sh
  • C shell (csh) at /bin/csh
  • Korn shell (ksh) at /bin/ksh
  • GNU bash /bin/bash

Here instead of directly specifying /bin/bash, we use the bash provided in the user environment configuration. You can check the content by running:

/usr/bin/env | grep shell

which should yield SHELL=/bin/bash or something alike. This conduct might provide better compatibility. It is explained here that this helps in the case where bash might not be in /bin (What about the path for env then, are we so sure about it? Seems more sure than bash ). Also, this allows you to adopt an alternative version of bash, or perhaps other shells if desired.

Fail Fast

set -Eeuo pipefail

To check the meaning of the settings, try:

set --help | grep -A 3 -- '-E\|-e\|-u\|-o'
  • -e Exit immediately if a command exits witha non-zero status
  • -o Options. For -o pipefaile, the return value of a pipeline (e.g. our script) is the status of the last command to exit with a non-zero status, or zero if none exited with a non-zero status
  • -u Treat unset variables as an error when substituting.
  • -E If set, the ERR trap is inherited by shell functions.
    Trap is a bash statement which performs certain actions upon pre-defined signals, such as SIGINT (signal-interrupt, typically from Ctrl+C), ERR, or EXIT. The syntax could be:
trap "{ echo 'Interrupted from user'; exit 0 }" SIGINT

Notice the space next to curly brackets must be preserved.
This trap statement displays a message upon Crtl+C and exit with zero state. Otherwise, the manual interrupt/manual kill will give some other exit state. Check the bash exit states here.

The -e options ensures that the script is stopped once an error is encountered, which helps avoid certain undesired mistakes. (For example, in our previous script, if the input file is not found, the script will stop instead of putting the “Copy” message line into the output file.)
The usage for other options are self-explanatory.

TODO: there are also arguments against such conduct of error handling.

Get the Location

script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)

This ensures that the working directory can be set to the path of the bash script regardless of where the script is executed from, e.g

/home/user/project/script.sh

Executing a bash script is different from source a bash script, in the sense that execution launches its own bash environment whereas source executes the script in the current environment.

. /home/user/project/script.sh
source /home/user/project/script.sh

BASH_SOURCE is a bash maintained variable, which is an array of source file path names. Each entry corresponds to the script path where the corresponding function in FUNCNAME variable is defined

FUNCNAME is a dynamic variable managed by bash as function stack tracing: An array variable containing the names of all shell functions currently in the execution call stack. The element with index 0 is the name of any currently-executing shell function. The bottom-most element (the one with the highest index) is “main”. This variable exists only when a shell function is executing. Assignments to FUNCNAME have no effect. If FUNCNAME is unset, it loses its special properties, even if it is subsequently reset.

“&> is a redirecting operator. As we know in linux everything is taken as a file, including the input, output and error message. They can be handled with descriptors 0, 1, and 2, corresponding to /dev/input, /dev/stdout and /dev/stderr. These files are then symbolic links to further process/device files. Often times we are interested in loading input from a file, or redirecting output and/or error message towards other files. &> redirects both stdout and stderr. In this case, I suppose they are redirected to /dev/null to avoid collision.”

pwd -P print physical address and avoids all symbolic links

Clean Up

trap cleanup SIGINT SIGTERM ERR EXIT
cleanup () {
	trap - SIGINT SIGTERM ERR EXIT
}

Define a function to do some cleanup, e.g. remove temporary files. The trap statement ensures it can be automatically triggered before exit, but it can still be used anywhere desired.

trap - SIG restores the default trap behavior, whereas trap “” SIG ignores it. This is to avoid a loop between cleanup and trap

Offer --help Functionality

usage() {
  cat <<EOF
Help script
EOF
  exit
}

The << identifier … identifier is a here document. Here document is a way to define a string in a shell script or other programming languages. The syntax ‘<< identifier’ tells the leading command to read until it finds the identifier, and treat all contents as a string input. The format and indent of the here document is preserved so that multiple line display can be easily formatted.

EOF, end-of-file is not a char, but rather a condition in a operating system where no more contents can be read from the file. In this case where we use EOF as the identifier of here document, notice that it must be at the beginning of the line and must not have trailing chars in the same line. Alternatively, use -EOF as the identifier allows the final identifier to be indented by tab only.

TODO: understand case switch syntax and $1 describer to implement --help functionality

Print Nice Messages

setup_colors() {
  if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then
    NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m'
  else
    NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE='' PURPLE='' CYAN='' YELLOW=''
  fi
}

msg() {
  echo >&2 -e "${1-}"
}

[[ … ]] is a bash syntax feature and is enhanced compare to [ … ]. It evaluates the expression quoted and returns a 0/1 result. Some advantages include better handling of spaces inside the brackets, and interpreting “&&” and “||” as logical operators.
Check bash conditional expression. The above expression evaluates to:

  • output (file 2) refers to terminal (-t)
  • The string ${NO_SOLOR} is empty (-z). If NO_COLOR is not defined, substitute with “”. This is necessary since we have set -u. For parameters expansion, {var:-default} or {var:default} evaluates to default if var is not defined. The difference between the two is that :- substitutes var="" with default whereas - does not, which suits our case better. For more parameter expansion, check here.
  • The string ${TERM} does not equal to “dumb”
    With all conditions met, the function defines a series of color variables, which can then be used in string for color output. -e option enables backslash expression evaluations, and >&2 redirects the echo stdout and stderr into stderr.
    >& is preferred than &> when redirecting stdout and stderr simultaneously

Parse Parameters

parse_params() {
  # default values of variables set from params
  flag=0
  param=''

  while :; do
    case "${1-}" in
    -h | --help) usage ;;
    -v | --verbose) set -x ;;
    --no-color) NO_COLOR=1 ;;
    -f | --flag) flag=1 ;; # example flag
    -p | --param) # example named parameter
      param="${2-}"
      shift
      ;;
    -?*) die "Unknown option: $1" ;;
    *) break ;;
    esac
    shift
  done

  args=("$@")

  # check required params and arguments
  [[ -z "${param-}" ]] && die "Missing required parameter: param"
  [[ ${#args[@]} -eq 0 ]] && die "Missing script arguments"

  return 0
}
  • Understand the while loop syntax and case switch syntax.
  • shift command renames positional argument $N+1 to $1, with N default to 1.
  • $@ represents all parameters. The functions are stacked.
  • TODO ${#args[@]}

The parser handles three types of CLI paramters: flags, named parameters and positional arguments:

  • flag: set flag=1 if -f is specified
  • named parameters: set param=value if -p value is specified
  • positional argument: parse -h -v, etc. and process accordingly

Use the Template

Bash dependency management is tricky, so better just copy-paste it. You may now tune the template to your taste:

  • Add description to usage()
  • Add functionalities to cleanup()
  • Tailor parameters
  • Code the script

Further Reading

TODO
A Bigger Template
Command Line Interface Guidelines
12 Factor CLI Apps
Command line arguments anatomy explained with examples

 类似资料:

相关阅读

相关文章

相关问答