bash 使用的安全方式

为什么使用Bash?

Bash有多个数组和安全模式,在正确运用的情况下,它会让安全编码实践可以被人接受。

Fish更容易正确运用,但是缺少一个安全模式。因此在fish中做原型是一个好主意,前提是你要知道如何从fish正确地翻译到bash。

前言

这个指南使用的是ShellHarden,但是作者也推荐了ShellCheck:ShellHarden的规则可能会与ShellCheck有所不同。

Bash不是一个语言,正确的做事方法也是最容易的。如果对安全bash编码来说也有像驾照一类的东西,那它肯定是BashPitfalls的第0条:每次都使用引号。

首先需要了解bash编码

疯狂引用! 一个无引号的变量将被视为武装炸弹:它在与空格接触时爆炸。是的,就像“爆炸”在将字符串切分为数组的情况类似。具体来说,变量扩展(如$ var)和命令替换(如$(cmd))会进行单词分割,在此会将包含的字符串通过特殊的$IFS变量(默认值为空格)扩展为数组。这大部分是不可见的,因为大多数情况下,结果是一个1元数组,与你期望的字符串难以区分。

不仅如此,通配符(*?)也被扩展了。这个过程发生在单词分割之后,这样当结果字包含任何通配符时,该单词现在是通配符模式,它将扩展到你可能碰巧存在的任何所匹配到的文件路径。 所以这个功能实际上取决于你的文件系统!

引号可以阻止单词分割和通配符扩展, 比如在一些变量和命令置换中.

变量扩展:

Good: "$my_var"

Bad: $my_var

命令置换:

Good: "$(cmd)"

Bad: $(cmd)

当然也有一些例外情况, 这时引号不是必须的. 但是带着引号也是人畜无害的, 况且通用规则就是当你看到未被引号包围的变量时就要小心一点, 所以对于读者来说, 过于追求这些不是太显而易见的例外情况是有问题的. 一些看起来错误的实践也足以引起我们的担心: 不能正确处理文件名中空格的大量脚本正在被写出来, 而这些是需要被避免的.

一些仅有的可以被接受的例外情况是代表数值内容的变量, 比如: $?, $# 和 ${#array[@]} 等.

我应该使用倒引号(backticks)吗?

命令的可替代格式如下:

正确: "`cmd`"

错误: `cmd`

虽然可以正确的使用这种格式,它在引号中看起来更笨拙,而且当嵌套的时候会更加难读。围绕这个问题的意见相当明确:避免使用。

Shellharden 将它们重写到dollar-括号形式。

我应该使用大括号吗?

大括号用于字符串插入语,也就是,一般来说没有必要。

不好的: some_command $arg1 $arg2 $arg3

不好且冗长的: some_command ${arg1} ${arg2} ${arg3}

好的但是冗长的: some_command "${arg1}" "${arg2}" "${arg3}"

好的: some_command "$arg1" "$arg2" "$arg3"

理论上来说,总是使用大括号没什么问题,但是以作者经验来讲,在不必要的使用大括号和正确使用引号之间有较强的负相关关系——几乎每个人不选择“好的却冗长”,而使用“错误且冗长”的格式。

作者的理论:

对错误情况的恐惧:一个初学者可能会担心名为$prefix的变量会影响"$prefix_postfix”的展开形式—— 这根本不是它的工作方式,而无视掉真正的危险(缺少引号)。

对新兴格式的崇拜(Cargo cult)——依照约定写代码,导致这对错误情况的恐惧一直存在。

在可以忍受的冗长的限制下,对使用大括号或引号的纠结。

为了禁止使用不必要的大括号,已经做出了决定:Shellharden会以最简单的好的形式重写这些变量。

现在来看基于字符串插值,即大括号真实的用途:

不好的 (连接): $var1"more string content"$var2

好的(连接): "$var1""more string content""$var2"

好的(插入): "${var1}more string content${var2}"

在bash中连接和插入是对等的(甚至是对于数组来说,这有点荒谬)。

因为Shellharden不是一个格式化工具,普遍认为它不会改变正确的代码。这对于“好的(连接)”的例子是没错的:就Shellharden而言,这是一个神圣的(规则上正确)格式。

在需要的基础上,Shellharden会及时添加或者删除大括号:在不好的例子中,var1会使用大括号变成插入语,但由于在字符串的末尾,是从不需要大括号的,即使在好的(插入)的例子中,var2的大括号也不会被识别。后者的需求可能会被无视。

数字参数

不像平常的标识符变量名一样(比如正则表达式: [_a-zA-Z][_a-zA-Z0-9]*), 数字参数需要用大括号括起来(无论是否有其它字符串内插). ShellCheck 会警告:

echo "$10" ^-- SC1037: Braces are required for positionals over 9, e.g. ${10}. (大于9的位置参数需要大括号)

而Shellharden 会拒绝这种操作(因为这个变量定义会引起歧义).

由于大于9的数字参数需要用大括号,因此Shellharden 允许所有的数字参数都可以使用大括号。

使用数组

为了能够引用所有变量,必须使用真正的数组,而不是用空格分隔的伪数组字符串。

语法虽然冗长,但是必须克服它。 这种bashism仅仅是为了减少大多数shellcript的posix兼容性。

好的使用方式:

array=( a b)array+=(c)if [ ${#array[@]} -gt 0 ]; then rm -- "${array[@]}"fi

差的使用方式:

pseudoarray=" \ a \ b "pseudoarray="$pseudoarray c"if ! [ "$pseudoarray" = '' ]; then rm -- $pseudoarrayfi

这就是为什么数组相当于只是一个shell的基本功能的原因:命令参数基本上是数组(而shell脚本都是关于命令和参数的)。 你可以说,一个无法通过人为的干净地传递多个参数的shell是不合适的。 这个类别中的一些广泛的shell包括Dash和Busybox Ash。 这些是最小的POSIX兼容shell - 当最重要的东西不在POSIX兼容范围中时又有什么好处呢?

那些实际上你打算分割字符串的例外情况

以 \v 作为分隔符的示例(请注意第二次出现):

IFS=$'\v' read -d '' -ra a <

这避免了通配符扩展,无论分隔符是不是 \n,它都能正常工作。 如果分隔符为空,则保留最后一个元素。 出于某种原因,-d选项必须首先出现,因此将选项放在一起作为-rad'',这是诱导人的,它不起作用。 用bash 4.2,4.3和4.4测试。

或者,对于bash 4.4,使用:

readarray -td $'\v' a <

如何开始编写一个bash脚本

像这样:

#!/usr/bin/env bashif test "$BASH" = "" || "$BASH" -uc "a=();true "\${a[@]}"" 2>/dev/null; then # Bash 4.4, Zsh set -euo pipefailelse # Bash 4.3 and older chokes on empty arrays with set -u. set -eo pipefailfishopt -s nullglob globstar

这里包括了:

hashbang:

可移植性考虑:env的绝对路径可能比bash的绝对路径更具可移植性。例如:NixOS。 POSIX强制要求env的存在,但bash不是一个posix的东西。

安全性考虑:这里没有像-eu pipefail这样的语言选项!使用env重定向实际上并不可行,但即使你的hashbang以#!/bin/bash开头,它也不会影响脚本含义中选项的正确位置,因为它可以被覆盖,这可能会以错误的方式运行你的脚本。但是,不会影响脚本含义的选项,如set -x会成为可覆盖(如果使用它)的可选值。

我们需要使用Bash的非官方严格模式,并在功能检查后面设置-u。我们并不需要所有Bash的严格模式,因为符合shellcheck/shellharden意味着引用所有内容,这是一种超越严格模式的级别。此外,在Bash 4.3及更早的版本中不能使用set -u。因为这些选项在这些版本中将空数组视为未设置,这使得数组无法用于此处。数组是本指南中第二个最重要的建议(在引用之后),也是牺牲POSIX兼容性的唯一原因,这当然是不可接受的:如果完全使用set -u,则使用Bash 4.4或Zsh等其他更明智的shell。如果有人可能会用过时版本的Bash运行脚本,那么说起来容易做起来难。幸运的是,使用set -u的工具也可以在没有数组(不像set -e)的情况下工作。因此,为什么把它放在功能检查之后是完全明智的。注意测试和开发是在Bash 4.4兼容shell的前提下(因此脚本的set -u方面经过测试)。如果这涉及到你,你的其他选择是放弃兼容性(如果功能检查失败,则失败)或放弃设置set -u。

shopt -s nullglob是当* .txt匹配零文件时使for f in *.txt正确工作的原因。默认状态(aka. passglob) - 如果它恰好没有匹配,按原样传递模式 - 这些原因是很危险的。至于globstar,可以实现递归通配。 Globbing比find更容易正确使用。所以请使用它。

但是不是这样使用:

IFS=''set -fshopt -s failglob

将internal field separator设置为空字符串将禁用分词。听起来像做梦。可悲的是,这并不是引用变量和命令替换的完全替代品,并且假设您将使用引号,这不会给您带来任何结果。您仍然必须使用引号,否则,空字符串将变为空数组(如在test $x =“”中),并且间接通配符扩展仍处于活动状态。此外,与这个变量混淆也会像使用它的read命令混淆,重新编写如cat/etc/fstab | while read -r dev mnt fs opt dump pass: do echo "$fs": dome'。

禁用通配符扩展:不仅是臭名昭着的间接扩展,而且也是无问题的直接扩展,我认为你应该使用它。所以这是一个很棘手东西。对于shellcheck/shellharden符合的脚本来说,这也是完全不必要的。

作为nullglob的替代方法,如果零匹配,failglob将失败。虽然这对大多数命令有意义,例如rm - * .txt(因为大多数带有文件参数的命令不会被调用,而且无论如何都不会调用它们),显然,failglob只能在您能够使用时使用假定零匹配不会发生。这只意味着你大多不会在命令参数中使用通配符,除非你假设相同。但可以做的是,使用nullglob并让模式扩展为可以接受零参数的构造,例如for循环或数组赋值(txt_files =(*.txt))中的零参数。

如何使用errexit

又叫set -e。

程序层面的延期清理

在errexit执行它的情况下,使用它来设置在退出时发生的任何必要的清理。

tmpfile="$(mktemp -t myprogram-XXXXXX)"cleanup() { rm -f "$tmpfile"}trap cleanup EXIT

抓住要点:Errexit在命令参数中被忽略

这是一个不错的拙劣的fork炸弹,我艰难的学会了如下方式 - 我的构建脚本在各种开发人员机器上运行良好,但是使我公司的构建服务器瘫痪了:

set -e # Fail if nproc is not installedmake -j"$(nproc)"

正确的(赋值中的命令替换):

set -e # Fail if nproc is not installedjobs="$(nproc)"make -j "$jobs"

警告:建立像local和export也是命令,所以这仍然是错误的:

set -e # Fail if nproc is not installedlocal jobs="$(nproc)"make -j"$jobs"

在这种情况下,ShellCheck仅有特殊的命令的警告,比如local。

set -e # Fail if nproc is not installedlocal jobsjobs="$(nproc)"make -j"$jobs"

抓住重点:Errexit被忽略取决于调用上下文

有时候,POSIX标准是残酷的。 如果调用者正在检查其成功,Errexit在函数中被忽略,甚至作用域或子shell层中被忽略。 尽管很理智,但这些例子都会被打印出“Unreachable”和“success”。

子shell:

( set -e false echo Unreachable) && echo Great success

作用范围:

{ set -e false echo Unreachable} && echo Great success

函数:

f() { set -e false echo Unreachable}f && echo Great success

这使得带有errexit的bash实际上是不能使用的 - 有可能可以包装你的errexit函数以使它们仍然可以工作,但是它节省的工作(通过显式的错误处理)会变得有问题。 考虑分割成完全独立的脚本。

如何避免使用不正确的引号调用shell

当通过编程语言来调用一个命令时,往往很容易出错:显式调用shell。如果shell命令是静态的,这是没问题的 —— 如若不然,它可能会正常工作,也可能不会。但是如果你的程序是对字符串进行某种处理然后再来组装成命令,也就是你正在生成一个shell脚本!你想要的很简单,但正确来做这件事是很单调的:

对每个参数加引号

在参数中避免使用相关字符

无论哪种编程语言,只要你使用这个格式,都有至少3种方法来正确地构建命令。按优先顺序排列:

方案A:避免shell

如果只是一条有参数的命令(例如像管道或者重定向这些无需shell特性的),选择数组来表示。

不好的(python3): subprocess.check_call('rm -rf ' + path)

好的 (python3): subprocess.check_call(['rm', '-rf', path])

不好的 (C++):

std::string cmd = "rm -rf ";cmd += path;system(cmd);

好的 (C/POSIX), minus error handling:

char* const args[] = {"rm", "-rf", path, NULL};pid_t child;posix_spawnp(&child, args[0], NULL, NULL, args, NULL);int status;waitpid(child, &status, 0);

方案B:静态shell脚本

如果必须使用shell,让参数就是参数。你可能觉得这有点累赘——将一个专门的shell脚本写到它自己的文件然后调用它——直到你遇到了这种技巧:

不好的(python3): subprocess.check_call('docker exec {} bash -ec "printf %s {} > {}"'.format(instance, content, path))好的(python3): subprocess.check_call(['docker', 'exec', instance, 'bash', '-ec', 'printf %s "$0" > "$1"', content, path])

你能认出这种shell脚本吗

没错,这就是重定向的printf命令。注意正确的对数字参数加引号。把它嵌入到一个静态的shell脚本是没问题的。

在Docker运行示例,因为如果不这样他们就不会有用了,但是Docker也是一个不错的例子:基于参数的运行其他命令的一条命令。它不像SSH,就像我们下面看到的。

最后的option: 字符串处理

如果它必须是一个字符串(例如,因为它必须通过ssh运行),则无法绕过字符串处理。我们必须对每个参数添加引号,并且转译那些在这些引号中必须转译的字符。最简单的办法是单引号,因为它有最简单的转义规则 - 只有一个:' → '\''。

不良写法(python3): subprocess.check_call('ssh user@host sha1sum ' + path)

不良写法 (python3): subprocess.check_call(['ssh', 'user@host', 'sha1sum', path])

通常正确写法 (python3): subprocess.check_call(['ssh', 'user@host', "sha1sum '{}'".format(path.replace("'", "'\\''"))])

为什么第二个示例不好呢?因为Ssh是不可信的:如果你试图给ssh多个参数,它会为你做错误的事情 - 在不加引号并使用空格连接参数时。

为什么这里没有“好”的示例,而仅有一个“通常正确的”示例呢?这是ssh的错:正确的解决方案取决于对端的用户偏好,即远程shell,它可以是任何东西。原则上,它可以是你的母亲。假设远程shell是bash或其他POSIX兼容shell,“通常正确”实际上是正确的,但fish在方面是不兼容的。