Przejdź do treści
Lekcja 105 z 117

Kurs › Moduł 11 › Lekcja 105

Bezpieczne skrypty

⏱ ok. 13 min czytania· moduł 11: Automatyzacja: Git, Ansible i Bash w pracy admina

Po tej lekcji: kursant trzyma hasła poza skryptem i poza historią Gita, pisze rm -rf tak, żeby pusta zmienna nie skasowała systemu, zastępuje eval tablicami i bezpiecznym cytowaniem oraz daje skryptom uprawnienia, których nie da się przejąć.

Wstęp

repozytorium na GitHubie, plik kopia.sh, podświetlona linijka z hasłem

Styczeń 2015. Użytkownicy Steama na Linuksie zgłaszają, że po uruchomieniu Steama zniknęły im wszystkie pliki. Nie tylko gry. Wszystko, do czego ich konto miało prawo zapisu. Winowajca to jedna linijka w skrypcie steam.sh:

rm -rf "$STEAMROOT/"*

Zmienna STEAMROOT w pewnych sytuacjach była pusta. Na przykład gdy ktoś przeniósł folder Steama. A pusta zmienna plus ukośnik plus gwiazdka to /*. Czyli „skasuj wszystko od korzenia”. Zgłoszenie ma numer 3671 w repozytorium steam-for-linux, poczytaj, bo to lepsze niż horror.

Skrypty z ostatnich lekcji chodzą jako root, po wielu serwerach naraz, z hasłami do baz. Dziś cztery rzeczy, które dzielą skrypt amatora od skryptu, który można zostawić na produkcji: hasła, rm -rf, eval i uprawnienia.

Hasła: gdzie ich nie trzymać

Hasło to klucz do sejfu. Pytanie nie brzmi „czy ktoś je zobaczy”, tylko „ile osób po drodze”. Trzy złe miejsca.

Złe miejsce 1: w treści skryptu. Skrypt ląduje w Gicie, w kopii zapasowej, na pendrivie kolegi. A w Gicie zostaje na zawsze. Zobacz: najpierw commit z hasłem, potem „naprawa”:

git log -p -G 'Zima' --oneline
41743db Hasło przeniesione do pliku cnf
-mysqldump -u backup -p"Zima2026" sklep > sklep.sql
9da73a9 Kopia bazy sklep
+mysqldump -u backup -p"Zima2026" sklep > sklep.sql

Hasła nie ma w aktualnej wersji pliku, ale historia pamięta. Kto sklonuje repozytorium, ten ma hasło. Wniosek jest jeden i bolesny: hasło, które trafiło do Gita, uznajesz za spalone i zmieniasz. Przepisywanie historii jest dopiero drugim krokiem.

Złe miejsce 2: w argumencie komendy. Linia poleceń każdego procesu jest publiczna. Dosłownie. Napisałem zły skrypt, który dostaje hasło jako argument, odpaliłem go jako root, a potem zajrzałem na zwykłym koncie ania:

sudo ./kopia-bazy.sh 'Zima2026!' &
su ania -c 'ps -eo user,pid,args' | grep '[k]opia-bazy'
root      2689 bash ./kopia-bazy.sh Zima2026!

Zwykły użytkownik widzi hasło roota. Bo ps czyta plik /proc/PID/cmdline, a ten ma prawa -r--r--r--, czyli czyta każdy. To jak wypisanie PIN-u flamastrem na karcie i noszenie jej w przezroczystym etui.

Ciekawostka: klient MariaDB wie o tym problemie i sam zamazuje hasło:

mariadb -h 10.255.255.1 --connect-timeout=5 -u backup --password=x xxxxxx

Ale robi to sam program, już po starcie. Twój skrypt w Bashu tego nie zrobi, a większość innych programów też nie.

Złe miejsce 3: zmienna środowiskowa „na wszelki wypadek”. Lepiej niż argument, bo /proc/PID/environ ma prawa -r--------:

cat: /proc/2689/environ: Permission denied

Ale zmienne dziedziczy każdy proces potomny, lądują w zrzutach błędów i w logach, gdy ktoś zrobi env przy debugowaniu. Do haseł nadaje się średnio.

Hasła: gdzie je trzymać

Dobre miejsce to osobny plik z prawami 600, poza repozytorium, czytany przez program bezpośrednio. Jak sejf w pokoju, do którego klucz ma tylko jeden lokator. MariaDB i MySQL mają na to gotowy mechanizm:

sudo install -d -m 700 /etc/kopia-bazy
sudo tee /etc/kopia-bazy/baza.cnf > /dev/null <<'EOF'
[client]
user=backup
password=Zima$2026!
EOF
sudo chmod 600 /etc/kopia-bazy/baza.cnf

I w skrypcie mysqldump --defaults-extra-file=/etc/kopia-bazy/baza.cnf sklep. Hasła nie ma ani w skrypcie, ani w ps. Zwróć uwagę na <<'EOF' w apostrofach: Bash nie rusza wtedy dolara w haśle.

Inne dobre sposoby:

  • interaktywnie: read -rsp 'Hasło: ' haslo. -s nie pokazuje znaków na ekranie;
  • przez standardowe wejście, jeśli program to umie, np. chpasswd czyta pary login:hasło ze stdin;
  • w Ansible: ansible-vault, który znasz już z lekcji 103.

A teraz dobry skrypt w całości. Pilnuje, żeby plik z hasłem był nasz i nikt inny go nie czytał:

#!/usr/bin/env bash
# kopia-bazy.sh BAZA - kopia bazy MariaDB/MySQL bez hasła w skrypcie
set -euo pipefail
umask 077
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

KONF=${KONF:-/etc/kopia-bazy/baza.cnf}   # tu jest hasło, prawa 600
CEL=${CEL:-/var/backups/bazy}

blad() { printf 'BŁĄD: %s\n' "$*" >&2; exit 1; }

(( $# == 1 )) || blad "użycie: ${0##*/} NAZWA_BAZY"
baza=$1
[[ $baza =~ ^[A-Za-z0-9_]+$ ]] || blad "dziwna nazwa bazy: $baza"

# Plik z hasłem: musi istnieć, być mój i nikt inny go nie czyta
[[ -f $KONF ]] || blad "brak pliku $KONF"
read -r prawa wlasciciel < <(stat -c '%a %u' -- "$KONF")
[[ $wlasciciel == "$EUID" ]] || blad "$KONF nie należy do mnie"
[[ $prawa == 600 || $prawa == 400 ]] || blad "$KONF ma prawa $prawa, ustaw: chmod 600 $KONF"

mkdir -p -- "$CEL"
tmp=$(mktemp -- "${CEL:?}/.${baza}.XXXXXX")
trap 'rm -f -- "$tmp"' EXIT

mysqldump --defaults-extra-file="$KONF" --single-transaction "$baza" | gzip > "$tmp"
plik="$CEL/$baza-$(date +%F).sql.gz"
mv -- "$tmp" "$plik"
trap - EXIT
printf 'OK: %s (%s)\n' "$plik" "$(du -h -- "$plik" | cut -f1)"

ShellCheck: czysto. Trzy przebiegi na mojej bazie testowej:

$ ./kopia-bazy.sh sklep
BŁĄD: /etc/kopia-bazy/baza.cnf ma prawa 644, ustaw: chmod 600 /etc/kopia-bazy/baza.cnf
$ sudo chmod 600 /etc/kopia-bazy/baza.cnf; ./kopia-bazy.sh sklep
OK: /var/backups/bazy/sklep-2026-11-04.sql.gz (4.0K)
$ ./kopia-bazy.sh 'sklep; id'
BŁĄD: dziwna nazwa bazy: sklep; id

Co tu jest dla bezpieczeństwa:

  • umask 077: wszystko, co skrypt utworzy, ma prawa tylko dla właściciela. Kopia bazy to też dane wrażliwe. U mnie wyszło -rw-------.
  • PATH ustawiony na sztywno: skrypt z crona albo sudo nie odpali przypadkiem „mysqldump” podrzuconego komuś w katalogu domowym.
  • Nazwa bazy sprawdzona wyrażeniem regularnym. Bramkarz wpuszcza tylko litery, cyfry i podkreślnik. Średnik nie przejdzie.
  • mktemp zamiast /tmp/kopia.$$. Przewidywalną nazwę ktoś mógłby podłożyć wcześniej jako link do /etc/shadow.
  • trap sprząta niedokończony plik. Sprawdziłem ze złym hasłem: mysqldump zgłasza Access denied, skrypt kończy się kodem 2, a w katalogu kopii zostaje tylko poprzednia, porządna kopia.

rm -rf, który nie zje systemu

rm -rf w skrypcie to piła łańcuchowa. Narzędzie świetne, ale nie zostawia się jej włączonej na stole. Wróćmy do Steama. Zobacz, co Bash podstawi przy pustej zmiennej. Zamiast rm daję echo, żeby nic nie kasować:

KATALOG_TMP=""
echo rm -rf "$KATALOG_TMP/"*
rm -rf /bin /bin.usr-is-merged /boot /common /container_info.json /dev ...

„Ale przecież rm ma ochronę przed rm -rf /!” Ma. W pomocy GNU rm stoi:

      --preserve-root[=all]  do not remove '/' (default);

Tylko że ochrona dotyczy samego /. A po rozwinięciu gwiazdki rm nie dostaje /, tylko /bin, /boot, /etc i tak dalej. Każdy z osobna wygląda niewinnie. Ochroniarz pilnuje głównych drzwi, a Ty wynosisz budynek oknami, pokój po pokoju.

Ochrona numer 1: ${zmienna:?komunikat}. Pusta albo nieustawiona zmienna zatrzymuje komendę, zanim cokolwiek się wykona:

echo rm -rf "${KATALOG_TMP:?pusta zmienna, nie kasuję}/"*
straz.sh: line 3: KATALOG_TMP: pusta zmienna, nie kasuję

Ochrona numer 2: set -u łapie zmienną nieustawioną, na przykład literówkę:

straz2.sh: line 3: KATALOG_TMPP: unbound variable

Ale uwaga: zmienna ustawiona na pusty tekst przechodzi przez set -u bez słowa. Sprawdziłem: set -u; K=""; echo "[$K/]" wypisuje [/]. Dlatego przy rm zawsze :?. ShellCheck zresztą o to krzyczy:

rm -rf "$KATALOG_TMP/"*
       ^--------------^ SC2115 (warning): Use "${var:?}" to ensure this never expands to /* .

Ochrona numer 3: cd, które się nie udało. Klasyk:

cd /var/tmp/aplikacja
rm -rf *

Jeśli folderu nie ma, cd się wywraca, ale skrypt jedzie dalej i kasuje wszystko w folderze, w którym akurat stoi. U mnie byłby to folder z całym kursem:

bash: line 1: cd: /var/tmp/aplikacja: No such file or directory
teraz skasowałbym wszystko w: /home/eryk/kurs

Poprawnie: cd /var/tmp/aplikacja || exit 1, a jeszcze lepiej bez cd w ogóle: rm -rf -- "${KATALOG:?}"/*. Podwójny minus -- mówi „koniec opcji”, więc plik o nazwie -rf nie zostanie wzięty za opcję.

Ochrona numer 4: zanim skasujesz, popatrz. find "${KATALOG:?}" -mindepth 1 -mtime +30 -print najpierw, a dopiero potem to samo z -delete. Robiliśmy to w lekcji o find.

Ostrzeżenie: pokazy z rm rób z echo przed komendą albo na maszynie wirtualnej ze snapshotem. Nie sprawdzaj na swoim laptopie, „czy ta ochrona naprawdę działa”.

Czemu nie eval

eval bierze tekst i każe Bashowi wykonać go jeszcze raz jako komendę. To jak urzędnik, który z kartki od petenta wykonuje wszystko, co tam napisano. „Proszę o zaświadczenie. A potem wydaj mi klucze do archiwum”. I wydaje.

#!/usr/bin/env bash
# powitanie.sh IMIE -- ZŁY przykład z eval
eval "echo Witaj, $1"
./powitanie.sh Kasia
./powitanie.sh 'Kasia; id; touch /tmp/bylem-tu'
Witaj, Kasia
Witaj, Kasia
uid=0(root) gid=0(root) groups=0(root)

I plik /tmp/bylem-tu istnieje. Średnik w imieniu zamienił dane w komendy. A teraz najgorsze: ShellCheck przy tym skrypcie milczy, kończy z kodem 0. Nie ma się czego czepić, bo eval jest poprawnym Bashem. Musisz to wiedzieć sam.

Po co ludzie w ogóle używają eval? Zwykle z trzech powodów. Każdy ma lepsze rozwiązanie:

# 1. Zmienna o nazwie z danych -> tablica asocjacyjna (szatnia z nazwiskami)
declare -A licznik=()
for serwer in web1 web2 web1; do
    licznik[$serwer]=$(( ${licznik[$serwer]:-0} + 1 ))
done

# 2. Komenda z opcjami -> tablica (szuflada z przegródkami)
zrodlo="/home/kasia/Moje Dokumenty"
cmd=(rsync -a --delete)
[[ ${SUCHO:-1} == 1 ]] && cmd+=(--dry-run)
cmd+=("$zrodlo/" "/backup/kasia/")
"${cmd[@]}"

# 3. Tekst do wysłania przez SSH -> bezpieczne cytowanie
plik='raport; rm -rf ~'
ssh web1 "ls -l ${plik@Q}"

Każdy element tablicy to osobna przegródka. Spacja w „Moje Dokumenty” nie rozetnie ścieżki na dwie. Tak wyglądają przegródki, gdy je wypiszę:

<rsync> <-a> <--delete> <--dry-run> </home/kasia/Moje Dokumenty/> </backup/kasia/>

Punkt 3 jest podstępny. ssh serwer "komenda" to eval w przebraniu, bo zdalny Bash czyta tekst od nowa. ${plik@Q} albo printf '%q' pakuje wartość w kopertę, której zdalny Bash nie rozerwie:

ssh web1 ls -l 'raport; rm -rf ~'

Trzeci eval w przebraniu to source plik.conf. Jeśli ktoś dopisze do konfiguracji NAZWA=$(touch /tmp/x; echo sklep), to source grzecznie wykona touch. Sprawdziłem, plik się pojawił. Konfigurację, której nie piszesz sam, czytaj jako tekst:

declare -A konf=()
while IFS='=' read -r klucz wartosc; do
    [[ $klucz =~ ^[A-Z_]+$ ]] || continue      # komentarze i śmieci pomijamy
    konf[$klucz]=$wartosc
done < "${1:?podaj plik konfiguracji}"
PORT     = 8080
KATALOG  = /var/lib/app
NAZWA    = $(touch /tmp/zrodlo; echo sklep)

Wartość została tekstem. Nikt niczego nie wykonał.

Uprawnienia skryptów

Skrypt, który odpala root, jest wart tyle, ile najsłabszy klucz do pokoju, w którym leży. Zrobiłem pokaz: skrypt kopia.sh należy do roota, prawa 755, czyli nikt poza rootem go nie zmieni. Ale folder narzedzia należy do zwykłego użytkownika ania:

drwxr-xr-x 2 ania    root 4096 Nov  4 08:23 narzedzia
-rwxr-xr-x 1 root    root   44 Nov  4 08:23 narzedzia/kopia.sh

Dopisanie do pliku nie przejdzie:

bash: line 1: /srv/demo/narzedzia/kopia.sh: Permission denied

Ale właściciel folderu może plik podmienić: kopiuje go, dopisuje swoją linijkę i przenosi na miejsce oryginału. Prawo do folderu to prawo do listy lokatorów, nie do mebli. Root odpala „swój” skrypt i:

Robię kopię...
PRZEJETE: root

Właśnie oddałeś roota. Zasady:

  • skrypt uruchamiany przez roota (cron, systemd, sudo) należy do root:root i ma prawa 755, 750 albo 700;
  • cała ścieżka do niego też należy do roota i nikt inny nie ma do niej prawa zapisu. Folder domowy użytkownika odpada;
  • instaluj jednym ruchem: sudo install -o root -g root -m 0750 kopia-bazy.sh /usr/local/sbin/.

Szybki przegląd, czy coś tu nie jest cudze albo zapisywalne dla wszystkich:

sudo find /usr/local/bin /usr/local/sbin /etc/cron* -type f \( -perm -o+w -o ! -user root \) -ls

A co z bitem SUID? Może by tak chmod u+s na skrypcie i wtedy każdy uruchomi go jako root? Sprawdziłem:

-rwsr-xr-x 1 root root 52 Nov  4 08:23 kim.sh
Działam jako: ania

Jądro Linuksa ignoruje SUID na skryptach. Strona man 2 execve mówi wprost, że Linux, jak większość nowoczesnych Uniksów, ignoruje bity set-user-ID i set-group-ID na skryptach. I dobrze. Chcesz dać komuś jeden skrypt jako root? Do tego jest sudo z pełną ścieżką i argumentami:

kasia ALL=(root) NOPASSWD: /usr/local/sbin/kopia-bazy.sh sklep

Taki plik sprawdzisz przed wgraniem: sudo visudo -cf plik mówi parsed OK. Kasia może zrobić kopię bazy sklep, a nie „cokolwiek, co ten skrypt umie”.

Jak to działa pod spodem

ps nie ma żadnych tajnych źródeł. Dla każdego procesu czyta katalog /proc/PID, a w nim cmdline (argumenty) i environ (zmienne). Jądro ustawia im różne prawa: cmdline czyta każdy, environ tylko właściciel procesu i root. Stąd różnica między hasłem w argumencie a w zmiennej.

eval działa tak: Bash najpierw normalnie rozwija linijkę, czyli podstawia $1. Powstaje tekst echo Witaj, Kasia; id. Potem eval oddaje ten tekst parserowi drugi raz i parser widzi średnik, czyli dwie komendy. Zwykłe echo "Witaj, $1" parsuje się raz, a wartość zmiennej po podstawieniu jest już tylko tekstem.

Czemu jądro ignoruje SUID na skryptach? Skrypt uruchamia się dwuetapowo: jądro czyta linijkę #!, odpala interpreter, a interpreter ponownie otwiera plik po nazwie. Między tymi krokami ktoś mógłby podmienić plik pod tą nazwą. Uprawnienia roota dostałby wtedy cudzy kod. Prościej było tę furtkę zamurować.

Ściąga flag

KomendaFlagaCo robi
${zmienna:?tekst}przerywa, gdy zmienna pusta albo nieustawiona
set-ubłąd przy nieustawionej zmiennej (pustej nie łapie)
rm--koniec opcji, plik -rf to tylko plik
rm--preserve-rootnie kasuje samego / (domyślnie), /* nie chroni
read-rsp 'Hasło: 'czyta bez echa na ekranie
umask077nowe pliki tylko dla właściciela
mktempSZABLON.XXXXXXplik tymczasowy o nieprzewidywalnej nazwie
mysqldump--defaults-extra-file=PLIKhasło z pliku, nie z linii poleceń
printf%qcytuje wartość dla Basha
${zmienna@Q}to samo co %q, w rozwinięciu
declare-Atablica asocjacyjna zamiast eval
install-o -g -mkopiuje z właścicielem i prawami
stat-c '%a %u'prawa liczbowo i UID właściciela
visudo-cf PLIKsprawdza składnię pliku sudoers
git log-p -G 'tekst'szuka tekstu w całej historii zmian

⚠ Typowy błąd

Przenosisz hasło do zmiennej na górze skryptu, „na chwilę, potem przeniosę do pliku”. Hasło bazy to Zima$2026!:

#!/usr/bin/env bash
HASLO="Zima$2026!"
mariadb -u backup -p"$HASLO" -e 'SELECT 1' sklep
ERROR 1045 (28000): Access denied for user 'backup'@'localhost' (using password: YES)

Hasło jest dobre, w konsoli MariaDB działa, a skrypt dostaje odmowę. Dlaczego? W cudzysłowie podwójnym Bash podstawia zmienne. $2 to drugi argument skryptu, czyli nic. Do bazy poleciało Zima026!. Koperta z cudzysłowów podwójnych jest przezroczysta dla dolara.

Z set -u dopisanym w drugiej linijce od razu widać winowajcę:

typowy.sh: line 3: $2: unbound variable

A ShellCheck łapie to jeszcze przed uruchomieniem, choć opisuje na swój sposób:

HASLO="Zima$2026!"
           ^-- SC1037 (error): Braces are required for positionals over 9, e.g. ${10}.

Naprawa doraźna: apostrofy, HASLO='Zima$2026!'. Naprawa właściwa: hasło do pliku .cnf z prawami 600, gdzie dolar jest zwykłym znakiem, i zmiana hasła, jeśli ten skrypt był już w Gicie.

✎ Zadanie

Weź trzy swoje skrypty z kursu, przepuść je przez ShellCheck i popraw wszystkie ostrzeżenia. Usuń z nich hasła na sztywno. Wrzuć je do repozytorium Git z sensownymi opisami commitów.

Pokaż rozwiązanie

Zadanie było: trzy skrypty przez ShellCheck, wywalić hasła na sztywno, wrzucić do Gita. Pokażę na jednym, reszta idzie tak samo. Oto „przed”:

#!/bin/bash
HASLO="Lato2026!"
plik=/srv/backup/sklep-$(date +%F).sql
mysqldump -u backup -p$HASLO sklep > $plik
echo "Zrobione: $plik"
In backup_bazy.sh line 4:
mysqldump -u backup -p$HASLO sklep > $plik
                                     ^---^ SC2086 (info): Double quote to prevent globbing and word splitting.

Zauważ, co ShellCheck zgłosił: brak cudzysłowu. A czego nie zgłosił? Hasła w drugiej linijce. ShellCheck pilnuje składni, nie sumienia. Hasło musisz wyrzucić sam. „Po”:

#!/bin/bash
set -euo pipefail
# Hasło leży w osobnym pliku z prawami 600, nie w skrypcie:
#   [client]
#   user=backup
#   password=...
DANE_LOGOWANIA="/etc/backup/mysql.cnf"
plik="/srv/backup/sklep-$(date +%F).sql"
mysqldump --defaults-extra-file="$DANE_LOGOWANIA" sklep > "$plik"
echo "Zrobione: $plik"
shellcheck backup_bazy.sh && echo "ShellCheck: czysto"
git init -b main .
printf '*.cnf\n*.env\n' > .gitignore
git add backup_bazy.sh .gitignore
git commit -m "backup_bazy: hasło przeniesione do pliku z prawami 600, cudzysłowy wg ShellChecka"
git log --oneline
ShellCheck: czysto
7cf21ff backup_bazy: hasło przeniesione do pliku z prawami 600, cudzysłowy wg ShellChecka

.gitignore z *.cnf i *.env to pas bezpieczeństwa: nawet jak ktoś zrobi git add ., pliki z hasłami nie pojadą do repozytorium. I ważne: jeśli hasło było już kiedyś w commicie, to usunięcie go z pliku nie wystarczy. Historia Gita pamięta wszystko, jak teściowa. Hasło trzeba po prostu zmienić.

Co dalej

To koniec modułu o automatyzacji. Od następnej lekcji zaczynamy przygotowanie do rozmowy rekrutacyjnej i na pierwszy ogień idą pliki i uprawnienia: link twardy kontra symboliczny, i-węzły, chmod 750, SUID, SGID i sticky bit, czyli pytania, na których wykłada się wielu kandydatów.