如何在bash中安全编程

为什么要扑朔迷离?


bash中有数组和安全模式。 如果正确使用,bash几乎与安全的编码惯例一致。

在鱼上犯错误比较困难,但是没有安全的模式。 因此,如果您知道如何正确制作鱼的原型,然后将其从鱼转变为猛击,将是一个好主意。

前言


该指南随ShellHarden一起提供,但作者还建议执行ShellCheck,以使ShellHarden规则与ShellCheck保持一致。

Bash不是同时解决问题最正确方法最简单的语言 。 如果您参加bash安全编程考试,那么BashPitfalls的第一条规则将是:始终使用引号。

您需要了解有关bash编程的主要知识


躁狂引号! 一个没有引号的变量应该被视为一个自爆炸弹:与空间接触会爆炸。 是的,它在将字符串分成数组的意义上爆炸。 特别是,由于将内部字符串扩展为数组时,由于将特殊的$IFS变量拆分为默认空间,因此将$var这样的变量扩展名和$(cmd)这样的命令替换项拆分为单词 。 这通常是不可见的,因为最常见的结果是一个由1个元素组成的数组,与预期字符串无法区分。

不仅扩展了它,还扩展了通配符( *? )。 拆分单词后会发生此过程,因此,如果单词具有至少一个通配符,则该单词会变成适用于任何合适文件路径的通配符。 因此,此功能开始应用于文件系统!

引用抑制了变量和命令替换的单词拆分和模式扩展。

变量扩展:

  • 好: "$my_var"
  • 错误: $my_var

命令替换:

  • 好: "$(cmd)"
  • 错误: $(cmd)

有一些带有可选引号的例外,但是引号永远不会受到损害,并且一般规则是要小心不要引用未引号的变量,因此我们不会为了您的利益而寻找边界例外。 它看起来是错误的,并且错误的做法已经广泛地引起人们的怀疑:许多脚本编写时对文件名和其中的空格进行了残破的处理...

ShellHarden仅提及少数例外-这些变量是否具有数字内容,例如$?$#${#array[@]}

我需要使用反引号吗?


命令替换也可以具有以下形式:

  • 正确: "`cmd`"
  • 错误: `cmd`

尽管可以正确使用此样式,但在引号中看起来不太方便,并且在嵌套时可读性较低。 这里的共识很明确:避免这样做。

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脚本


从这样的事情:

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

这包括:

  • 舍邦:
    • 可移植性问题: 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 # Fail if nproc is not installed make -j"$(nproc)" 

正确(任务中的命令替换):

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

警告: localexport内置命令仍然是命令,因此这仍然是错误的:

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

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

要使用local ,请将声明与作业分开:

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

捕获: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是“通常正确的”。

Source: https://habr.com/ru/post/zh-CN413117/


All Articles