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在方面是不兼容的。


分享到:


相關文章: