为什么要扑朔迷离?
bash中有数组和安全模式。 如果正确使用,bash几乎与安全的编码惯例一致。
在鱼上犯错误比较困难,但是没有安全的模式。 因此,如果您知道如何正确制作鱼的原型,然后将其从鱼转变为猛击,将是一个好主意。
前言
该指南随ShellHarden一起提供,但作者还建议执行
ShellCheck,以使ShellHarden规则与ShellCheck保持一致。
Bash不是
同时解决问题的
最正确方法最简单的语言 。 如果您参加bash安全编程考试,那么
BashPitfalls的第一条规则将是:始终使用引号。
您需要了解有关bash编程的主要知识
躁狂引号! 一个没有引号的变量应该被视为一个自爆炸弹:与空间接触会爆炸。 是的,它在
将字符串分成数组的意义上爆炸。 特别是,由于将内部字符串扩展为数组时,由于将特殊的
$IFS
变量拆分为默认空间,因此将
$var
这样的变量扩展名和
$(cmd)
这样的命令替换项拆分为
单词 。 这通常是不可见的,因为最常见的结果是一个由1个元素组成的数组,与预期字符串无法区分。
不仅扩展了它,还扩展了通配符(
*?
)。 拆分单词后会发生此过程,因此,如果单词具有至少一个通配符,则该单词会变成适用于任何合适文件路径的通配符。 因此,此功能开始应用于文件系统!
引用抑制了变量和命令替换的单词拆分和模式扩展。
变量扩展:
命令替换:
有一些带有可选引号的例外,但是引号永远不会受到损害,并且一般规则是要小心不要引用未引号的变量,因此我们不会为了您的利益而寻找边界例外。 它看起来是错误的,并且错误的做法已经广泛地引起人们的怀疑:许多脚本编写时对文件名和其中的空格进行了残破的处理...
ShellHarden仅提及少数例外-这些变量是否具有数字内容,例如
$?
,
$#
和
${#array[@]}
。
我需要使用反引号吗?
命令替换也可以具有以下形式:
尽管可以正确使用此样式,但在引号中看起来不太方便,并且在嵌套时可读性较低。 这里的共识很明确:避免这样做。
ShellHarden用美元将这些复选标记重写在方括号中。
是否需要使用花括号?
括号用于插入字符串,因此它们通常是多余的:
- 错误:
some_command $arg1 $arg2 $arg3
- 较差且冗长:
some_command ${arg1} ${arg2} ${arg3}
- 很好,但很冗长:
some_command "${arg1}" "${arg2}" "${arg3}"
- 好:
some_command "$arg1" "$arg2" "$arg3"
从理论上讲,始终使用花括号不是问题,但是根据您的作者的经验,不必要地使用花括号与正确使用引号之间存在很强的负相关关系-几乎每个人都选择“不好而冗长”而不是“好而冗长”的形式!
您的作者的理论:
- 由于担心做错事情:初学者可能会担心
$prefix
变量会导致"$prefix_postfix"
变量扩展,而不是真正的危险(没有引号),但这种方式无法正常工作。 - 货运邪教:在出现错误恐惧之前立下代码。
- 括号与引号竞争允许的冗长限制。
因此,决定禁止使用不必要的花括号: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, eg ${10}.
ShellHarden拒绝修复它(认为差异太微妙了)。
由于括号最多允许9个,ShellHarden允许它们用于所有编号的参数。
使用数组
为了能够引用所有变量,必须使用实数数组,而不是用空格分隔的伪大量字符串。
语法很冗长,但是您必须处理它。 这种Bashism只是对于大多数Shell脚本放弃POSIX兼容性的原因之一。
好:
array=( a b ) array+=(c) if [ ${#array[@]} -gt 0 ]; then rm -- "${array[@]}" fi
不好:
pseudoarray=" \ a \ b \ " pseudoarray="$pseudoarray c" if ! [ "$pseudoarray" = '' ]; then rm -- $pseudoarray fi
这就是为什么数组是shell如此基本的功能的原因:
命令的
参数从根本上说就是数组 (而shell脚本是命令和参数)。 可以说,人为地使其无法通过几个论点的外壳将是可笑且毫无价值的。 此类别中的一些常见外壳包括
Dash和Busybox Ash。 这些是最小的POSIX兼容外壳程序-但是,如果最重要的内容
不在 POSIX上,则兼容性有什么好处?
当您真的要折线的特殊情况
使用
\v
作为数据分隔符的示例(请注意第二次出现):
IFS=$'\v' read -d '' -ra a < <(printf '%s\v' "$s") || true
这样,我们避免了模板扩展,即使数据分隔符为
\n
,该方法仍然有效。 如果最后一个元素是空格,则第二次出现的数据分隔符将保护最后一个元素。 出于某种原因,应该先使用
-d
选项,因此
-rad ''
选项
-rad ''
诱人,但无效。 由于在这种情况下read返回一个非零值,因此如果启用,则应防止errexit(
|| true
)。 在bash 4.0、4.1、4.2、4.3和4.4中进行了测试。
bash 4.4的替代方法:
readarray -td $'\v' a < <(printf '%s\v' "$s")
在哪里启动bash脚本
从这样的事情:
这包括:
- 舍邦:
- 可移植性问题:
env
的绝对路径对于可移植性可能比bash
的绝对路径更好。 您可以看一下NixOS的示例。 POSIX需要env ,但不需要bash。 - 安全问题:对于没有语言的用户,
-euo pipefail
不会接受-euo pipefail
类的选项! 使用env
重定向时,这变得不可能,但是即使您的爆炸以#!/bin/bash
开头,也不是影响脚本值的参数的位置,因为可以覆盖它们,这将使错误地执行脚本成为可能。 但是,作为奖励,可以重新定义不影响脚本值的选项,例如set -x
(如果使用的话)。
- 通过
set -u
功能检查,我们需要从非官方的Bash严格模式中获得什么。 我们不需要所有严格的Bash模式,因为shellcheck / shellharden兼容性意味着引用所有严格的内容。 此外,在Bash 4.3及更早版本中不应使用 set -u
选项。 由于此选项将空数组视为那些版本中丢弃的数组,因此不能将数组用于此处描述的目的。 使用数组是本指南中第二个最重要的技巧(在引号后),并且是我们牺牲与POSIX兼容性的唯一原因,因此这绝不是不可接受的:要么根本不使用set -u
要么不使用Bash 4.4或另一个普通的Shell(如Zsh)。 说起来容易做起来难,因为有人可能仍会在旧版的Bash中运行您的脚本。 幸运的是,所有与set -u
一起使用的东西都可以在没有它的情况下工作(对于set -e
您不能这么说)。 这就是使用版本检查很重要的原因。 注意以下假设:测试和开发是在与Bash 4.4兼容的shell中进行的(因此对set -u
方面进行了测试)。 如果这使您感到困扰,则另一种选择是拒绝兼容性(版本验证失败时脚本将失败),或拒绝set -u
。 - 如果
*.txt
未找到文件, shopt -s nullglob
强制for f in *.txt
正常工作。 默认行为(aka passglob )不变地传递模板,如果结果为零,则出于多种原因这很危险。 对于globstar,这将激活递归查找。 替换比find
更容易使用。 因此使用它。
但不是:
IFS='' set -f shopt -s failglob
- 将内部字段定界符设置为空字符串将无法拆分单词。 听起来像是完美的解决方案。 不幸的是,这是对引号变量和命令替换的不完全替代,并且由于您将使用引号,因此它什么也没有。 之所以仍然需要使用引号,是因为否则空字符串会变成空数组(如
test $x = ""
),并且仍然可以进行间接模板扩展。 此外,此变量的问题还会导致read
cat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done'
命令出现问题,这会破坏cat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done'
构造cat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done'
cat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done'
cat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done'
。 - 模板扩展名被禁用:不仅是臭名昭著的间接扩展名,而且还有麻烦的直接扩展名,正如我所说,您应该使用它。 因此很难接受。 对于shellcheck / shellharden兼容脚本,这也是完全可选的。
- 与nullglob不同, failglob失败,结果为空。 尽管对于大多数命令来说这是有意义的,例如rm-
rm -- *.txt
(因为对于大多数命令来说,仍然不希望执行结果为零),但是显然只有在您不希望结果为零时才可以使用failglob 。 这意味着,除非您假设相同,否则通常不会在命令参数中放置组模板。 但是,总会发生的事情是使用nullglob并将模板扩展为可以接受它们的构造中的null参数,例如循环或将值分配给数组( txt_files=(*.txt)
)。
如何完成bash脚本
脚本退出状态是最后执行的命令的状态。 确保它代表真正的成功或失败。
最糟糕的事情是在脚本末尾以AND列表的形式将解决方案置于不相关的条件下。 如果条件为假,则最后执行的命令将为条件本身。
对于errexit,绝不会首先使用AND列表形式的条件。 如果不使用errexit,则即使对于最后一个命令也要考虑处理错误,因此,如果将其他代码添加到脚本中,则不会掩盖其退出状态。
不好:
condition && extra_stuff
好(errexit选项):
if condition; then extra_stuff fi
良好(错误处理选项):
if condition; then extra_stuff || exit fi exit 0
如何使用errexit
像
set -e
一样。
程序级延迟清除
如果errexit可以正常工作,请使用它在出口上安装所有必要的清除程序。
tmpfile="$(mktemp -t myprogram-XXXXXX)" cleanup() { rm -f "$tmpfile" } trap cleanup EXIT
捕获:errexit在命令参数中被忽略
这是一个非常棘手的分支“炸弹”,对此的理解对我来说非常有价值。 我的构建脚本在不同的开发机器上都可以正常工作,但是将构建服务器放在了膝盖上:
set -e
正确(任务中的命令替换):
set -e
警告:
local
和
export
内置命令仍然是命令,因此这仍然是错误的:
set -e
在这种情况下,ShellCheck仅警告诸如
local
特殊命令。
要使用
local
,请将声明与作业分开:
set -e
捕获:errexit被忽略,具体取决于调用方的上下文
有时POSIX太糟糕了。 如果调用方检查其成功,则Errexit在函数,组命令甚至子Shell中都将被忽略。 所有这些示例均显示出
Unreachable
and
Great success
,尽管看起来似乎很奇怪。
子壳:
( 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脚本 ! 我很少要这样做,很累,要正确地安排所有事情:
无论您使用哪种编程语言,至少都有三种方法可以正确地建立团队。 按优先顺序:
计划A:不带壳
如果这只是一个带有参数的命令(也就是说,没有诸如管道传递或重定向之类的外壳函数),则选择一个数组选项。
- 错误(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),减去错误处理:
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脚本并访问它很麻烦,直到您看到这样的窍门:
错误(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不同,我们将在后面看到。
最后选择:行处理
如果它
应该是一个字符串(例如,因为它必须通过
ssh
起作用),则不能绕过它。 您将必须引用每个参数,并转义退出这些引用所需的任何字符。 最简单的方法是切换到单引号,因为它们具有最简单的转义规则。 只有一个规则:
'
→
'\"
。
典型的单引号文件名:
echo 'Don'\''t stop (12" dub mix).mp3'
如何使用此技巧安全地执行ssh命令? 这是不可能的! 好吧,这是“经常正确”的解决方案:
- “通常正确”的解决方案(python3):
subprocess.check_call(['ssh', 'user@host', "sha1sum '{}'".format(path.replace("'", "'\\''"))])
我们自己必须将所有参数组合成一个字符串,以使Ssh不会做错:如果尝试传递多个ssh参数,它将开始以欺骗性的方式组合不带引号的参数。
通常不可能这样做的原因是,正确的决定取决于另一端用户的偏好,即远程外壳,它可以是任何东西。 基本上,甚至可能是你妈妈。 假定远程外壳是bash或另一个POSIX兼容外壳,但
在此阶段不兼容fish是“通常正确的”。