快轉到主要內容
  1. 教學文章/

Python shlex 實戰:安全拆解 Shell 參數、Quote 與迷你指令語法

·6 分鐘· loading · loading · ·
Python Shlex Shell Cli Security Standard-Library
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 100: 本文

一. 前言:字串像命令,不代表可以直接執行
#

設定檔裡有這一行:

resize --width 1200 --label "拍拍君 sample.png"

你想把它拆成程式名稱與參數。 直接呼叫 command.split(),會把引號裡的檔名切成兩段。 把整串交給 shell,又會讓 $HOME;|$(...) 開始工作,安全邊界瞬間變得很刺激。 Python 標準庫的 shlex 專門處理這個中間地帶:

  • 按照 Unix shell 類似規則拆解字串
  • 保留引號包住的空白
  • 把參數安全地 quote 回命令列文字
  • 建立可調整的 lexical analyzer
  • 處理簡單的 shell-like 設定

它不是 shell,也不會執行命令。 這個限制反而是它最重要的優點。 如果你要學啟動程序、拿 stdout 與設定 timeout,先看 Python subprocess 完整指南。 今天只處理「文字怎麼切成 token」。

二. 安裝:標準庫已經準備好了
#

shlex 不需要 pip install

mkdir shlex-lab
cd shlex-lab
python3 -c "import shlex; print(shlex.split('hello world'))"

也可以用 uv 固定環境:

uv init
uv python pin 3.13
uv run python -c "import shlex; print(shlex.split('hello world'))"

預期輸出:

['hello', 'world']

本文使用 Python 3.13 API;join() 需要 Python 3.8 以上。

三. shlex.split():正確看懂引號
#

先比較最常見的錯誤與正解:

import shlex
command = 'resize --label "拍拍君 sample.png" --width 1200'
print(command.split())
print(shlex.split(command))

輸出:

['resize', '--label', '"拍拍君', 'sample.png"', '--width', '1200']
['resize', '--label', '拍拍君 sample.png', '--width', '1200']

str.split() 只認空白。 shlex.split() 理解單引號、雙引號與跳脫字元:

samples = [
    r"convert photo.png --name 拍拍君",
    r"convert 'my photo.png' --name '拍拍君 demo'",
    r'convert "my photo.png" --name "拍拍君 demo"',
    r"convert my\ photo.png --name pypy",
]
for text in samples:
    print(shlex.split(text))

引號是語法,不是資料,所以結果不保留外層引號。 這適合解析設定檔中的單行參數、測試案例與不需要完整 shell 能力的迷你指令欄位。

四. POSIX 模式與註解
#

split() 預設使用 POSIX mode,也可以明確指定:

text = r'''tool "hello world" '拍拍君 data' '''
print(shlex.split(text, posix=True))
print(shlex.split(text, posix=False))

POSIX mode 會移除語法用引號;non-POSIX mode 可能保留引號。 新程式建議明寫 posix=True,讓規則清楚。 註解則由 comments 控制:

text = 'deploy --env staging # tonight'
print(shlex.split(text, comments=False))
print(shlex.split(text, comments=True))

結果:

['deploy', '--env', 'staging', '#', 'tonight']
['deploy', '--env', 'staging']

引號裡的 # 仍是資料:

text = r'''tag --name "拍拍君 #1"'''
print(shlex.split(text, comments=True))

是否允許註解要由格式契約決定,不要看到 # 就自己截字串。

五. shlex.join():把 token 轉回可讀命令
#

程式內部有一組正確參數:

args = [
    "resize",
    "--input",
    "拍拍君 photo.png",
    "--label",
    "it's ready",
]

" ".join(args) 會失去 token 邊界。 改用:

display = shlex.join(args)
print(display)

join() 適合產生可複製的 POSIX 命令顯示、process invocation log 與 debug 訊息。 它的重要性質是:

assert shlex.split(shlex.join(args)) == args

引號外觀可能改變,但 token list 應保持相同。 測試資料語意,不要測 quote 長相。

六. shlex.quote():只保護一個參數
#

quote() 接受單一字串,回傳可安全放進 POSIX shell command line 的版本:

filename = "拍拍君's report; final.txt"
print(shlex.quote(filename))

別把整條 script 一起 quote:

# 錯誤觀念:整串會變成一個 shell word
shlex.quote("cat report.txt | wc -l")

多個參數要用 join()。 真正執行外部程式時,通常直接傳 list 更安全:

import subprocess
subprocess.run(
    ["cat", "拍拍君 report.txt"],
    check=True,
)

這樣不需要 shell,也不需要 quote。 安全工具的最高境界,常常是讓危險語法沒有出場機會。

七. 解析、驗證與執行要分層
#

請把工作流切成三層:

  1. 解析:文字轉成 token list
  2. 驗證:確認程式與參數是否允許
  3. 執行:以 list 交給 subprocess.run()
from __future__ import annotations
import shlex
import subprocess
ALLOWED_PROGRAMS = {"echo", "printf"}
def parse_command(text: str) -> list[str]:
    args = shlex.split(text, posix=True)
    if not args:
        raise ValueError("命令不能是空白")
    if args[0] not in ALLOWED_PROGRAMS:
        raise ValueError(f"不允許執行:{args[0]}")
    return args
def run_command(text: str) -> str:
    result = subprocess.run(
        parse_command(text),
        check=True,
        capture_output=True,
        text=True,
        shell=False,
    )
    return result.stdout

即使使用 shlex.split(),也不能跳過 allowlist 與參數驗證。 拆成 token 不代表每個 token 都符合產品規則。 安全不是一個函式呼叫,而是一層一層縮小權限。

八. shlex 不是 Shell 模擬器
#

text = 'echo "$HOME" | sed "s/x/y/" && echo done'
print(shlex.split(text))

shlex 不會:

  • 展開 $HOME
  • 執行 command substitution
  • 建立 pipe 或 redirect
  • 套用 glob
  • 執行 && 的短路邏輯

如果不需要 shell operator,最好直接拒絕:

FORBIDDEN = {"|", "||", "&", "&&", ";", ">", ">>", "<"}
def reject_shell_operators(args: list[str]) -> None:
    found = FORBIDDEN.intersection(args)
    if found:
        names = ", ".join(sorted(found))
        raise ValueError(f"不支援 shell operator:{names}")

這仍不是完整 shell 安全檢查。 最穩定的設計,是讓使用者填結構化欄位,而不是自由 shell 文字。

九. shlex.shlex:客製 Lexer
#

split() 是便利函式。 要調整 token 規則,可以建立 shlex.shlex

text = '''
# 拍拍君的部署設定
target staging
region ap-east
message "release candidate"
'''
lexer = shlex.shlex(text, posix=True)
lexer.whitespace_split = True
lexer.commenters = "#"
print(list(lexer))

輸出:

['target', 'staging', 'region', 'ap-east', 'message', 'release candidate']

常用屬性包括:

  • whitespace_split:是否主要按空白切詞
  • commenters:哪些字元開始註解
  • wordchars:哪些字元留在一般 word
  • quotes:哪些字元是 quote
  • escape:跳脫字元

修改規則前先寫測試。 Lexer 的 bug 特別愛躲在引號、註解與空字串交界。

十. punctuation_chars 與迷你設定語法
#

若要辨認 |&&;,可以在建立時設定:

lexer = shlex.shlex(
    "build --fast && deploy | notify",
    posix=True,
    punctuation_chars=True,
)
lexer.whitespace_split = True
print(list(lexer))

也能指定自己關心的字元,做有限的 key-value 語法:

def parse_config_line(text: str) -> tuple[str, str]:
    lexer = shlex.shlex(
        text,
        posix=True,
        punctuation_chars="=",
    )
    lexer.whitespace_split = True
    lexer.commenters = "#"
    tokens = list(lexer)
    if len(tokens) != 3 or tokens[1] != "=":
        raise ValueError("格式必須是:key = value")
    key, _, value = tokens
    if not key.replace("_", "").isalnum():
        raise ValueError(f"不合法的 key:{key}")
    return key, value
print(parse_config_line('name = "拍拍君 worker"'))
print(parse_config_line("retries = 3"))

Tokenize operator 不等於正確解讀 operator。 需求出現巢狀結構、陣列或型別時,請改用 TOML、JSON 或真正的 parser。 專案設定通常更適合 tomllib 教學 的做法。

十一. 錯誤處理與 Windows 限制
#

未關閉引號會丟出 ValueError

try:
    shlex.split('deploy --message "unfinished')
except ValueError as exc:
    print(f"解析失敗:{exc}")

批次讀檔時,要補上來源行號:

from pathlib import Path
def load_commands(path: Path) -> list[list[str]]:
    commands: list[list[str]] = []
    lines = path.read_text(encoding="utf-8").splitlines()
    for number, line in enumerate(lines, start=1):
        if not line.strip():
            continue
        try:
            args = shlex.split(line, comments=True, posix=True)
        except ValueError as exc:
            raise ValueError(f"{path}:{number}: {exc}") from exc
        if args:
            commands.append(args)
    return commands

另外,shlex 的目標是 Unix shell syntax。 posix=False 不等於完整的 cmd.exe 或 PowerShell parser,quote() 也不保證適用 Windows shell。 跨平台工具應維持結構化參數:

args = [program, "--input", input_path, "--name", display_name]
subprocess.run(args, check=True, shell=False)

不要先 shlex.join(args),再把字串交給 Windows shell。 那是把 POSIX 顯示格式誤當跨平台執行格式。

十二. 測試 Round-trip 與惡意字元
#

Tokenizer 適合 table-driven tests:

import pytest
@pytest.mark.parametrize(
    ("text", "expected"),
    [
        ("hello world", ["hello", "world"]),
        ('hello "拍拍君 world"', ["hello", "拍拍君 world"]),
        (r"hello empty=''", ["hello", "empty="]),
        (r"hello a\ b", ["hello", "a b"]),
        ("echo '; rm nope'", ["echo", "; rm nope"]),
    ],
)
def test_split(text: str, expected: list[str]) -> None:
    assert shlex.split(text, posix=True) == expected

再測 token round-trip:

@pytest.mark.parametrize(
    "args",
    [
        [],
        ["echo", ""],
        ["echo", "拍拍君 data.txt"],
        ["printf", "%s", "a;b&c"],
        ["tool", "it's ready", 'say "hi"'],
    ],
)
def test_join_round_trip(args: list[str]) -> None:
    assert shlex.split(shlex.join(args)) == args
def test_unclosed_quote_is_rejected() -> None:
    with pytest.raises(ValueError, match="quotation"):
        shlex.split('echo "unfinished')

測試至少涵蓋空 token、空白、引號、backslash、註解、metacharacters、Unicode 與未關閉引號。 想深入參數化與 fixture,可接著看 pytest fixtures 實戰

十三. 選擇指南與常見反模式
#

需求 建議
拆解簡單 Unix shell-like 參數 shlex.split()
把 argv 顯示成 POSIX 命令 shlex.join()
保護單一 POSIX shell token shlex.quote()
執行程式且不需要 shell subprocess.run(list, shell=False)
解析 TOML tomllib
解析 Bash / PowerShell / cmd.exe 專用 parser,或避免自由 script

幾個常見反模式:

  1. str.split() 解析帶引號參數。
  2. shlex.split() 後又 join(),最後丟給 shell=True
  3. 以為 quote() 會驗證 executable 與參數語意。
  4. shlex 硬疊出巢狀程式語言。
  5. 把 POSIX round-trip 當成跨平台保證。

拍拍君的預設策略是:能用 list 就不要收自由命令字串;必須收字串時先 tokenize,再做 allowlist 與參數驗證;能維持 shell=False 就維持。

結語
#

shlex 很小,卻站在人類文字與程式參數的危險邊界。 今天記住:

  • str.split() 不懂引號,shlex.split() 才懂 shell-like token
  • join() 用來產生 POSIX-style 顯示文字
  • quote() 只保護單一參數
  • 解析不等於驗證,驗證也不等於執行
  • subprocess.run([...], shell=False) 通常比組字串安全
  • shlex 是 lexer,不是完整 shell parser
  • Unix quoting 不應直接套到 Windows shell

先把文字變成明確資料,再決定程式可以做什麼。 這個小習慣會讓 CLI、automation 與部署工具安靜很多。 安靜是好事,尤其在安全事件發生前。🐍

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 100: 本文

相關文章

Python secrets 實戰:安全產生 Token、密碼與一次性連結
·7 分鐘· loading · loading
Python Secrets Security Token Standard-Library Web
Python socket 實戰:TCP client/server、timeout 與簡易通訊協定
·8 分鐘· loading · loading
Python Socket TCP Networking Standard-Library Developer-Tools
Python shutil 實戰:檔案複製、搬移、壓縮與安全清理
·7 分鐘· loading · loading
Python Shutil Filesystem Automation Standard-Library Developer-Tools
Python inspect 實戰:看懂函式簽名、物件結構與開發工具自動化
·6 分鐘· loading · loading
Python Inspect Introspection Standard-Library Developer-Tools
Rich Logging Dashboard 實戰:進度、表格與 Log Console 整合
·7 分鐘· loading · loading
Python Rich Logging Cli Dashboard Developer-Tools
Python zoneinfo 實戰:時區、DST 與排程時間處理完全攻略
·8 分鐘· loading · loading
Python Zoneinfo Datetime Timezone DST Standard-Library