Przejdź do treści
Lekcja 45 z 117

Kurs › Moduł 4 › Lekcja 45

Wyrażenia regularne

⏱ ok. 11 min czytania· moduł 4: Napisy, listy i przetwarzanie danych

Po tej lekcji: kursant pisze proste wyrażenia regularne, wycina nimi z logów konkretne kawałki (grep -E, grep -o) i sprawdza dane w skrypcie przez [[ =~ ]] z tablicą BASH_REMATCH.

Wstęp

plik /var/log/auth.log, tysiące linijek „Failed password” przewijają się w nieskończoność

Poniedziałek, ósma rano. Na serwerze przez noc ktoś próbował się zalogować dwa tysiące razy. Szef pyta: „Kto nas atakuje? Z jakich adresów i ile razy?”.

Adresy IP są w środku linijek, raz tu, raz tam. cut nie pomoże, bo kolumny się rozjeżdżają. Ale jest taka linijka:

grep 'Failed' auth.log | grep -oE '[0-9]{1,3}(\.[0-9]{1,3}){3}' | sort | uniq -c | sort -rn
      3 203.0.113.45
      2 198.51.100.7
      1 192.0.2.10

Wiem. Ten środek wygląda, jakby kot przeszedł po klawiaturze. Dziś nauczysz się czytać kocie ślady. Za piętnaście minut ta linijka będzie dla Ciebie tak oczywista, jak przepis na jajecznicę.

Ćwiczymy na małym pliku. Prawdziwy log jest w /var/log/auth.log [Ubuntu] albo w /var/log/secure [RHEL] i czyta się go przez sudo. Zrób sobie plik auth.log z takimi linijkami:

Nov  3 02:14:07 serwer sshd[1201]: Failed password for root from 203.0.113.45 port 51122 ssh2
Nov  3 02:14:09 serwer sshd[1201]: Failed password for root from 203.0.113.45 port 51122 ssh2
Nov  3 02:15:31 serwer sshd[1207]: Failed password for invalid user admin from 198.51.100.7 port 40022 ssh2
Nov  3 02:15:33 serwer sshd[1207]: Failed password for invalid user admin from 198.51.100.7 port 40022 ssh2
Nov  3 02:16:02 serwer sshd[1213]: Failed password for root from 203.0.113.45 port 51190 ssh2
Nov  3 07:58:12 serwer sshd[1450]: Accepted publickey for eryk from 192.0.2.10 port 50544 ssh2
Nov  3 08:01:44 serwer sshd[1462]: Failed password for eryk from 192.0.2.10 port 50601 ssh2
Nov  3 08:01:50 serwer sshd[1462]: Accepted password for eryk from 192.0.2.10 port 50601 ssh2

List gończy zamiast nazwiska

Zwykłe szukanie to szukanie Jana Kowalskiego. Znasz nazwisko, wołasz po nazwisku.

Ale co, jeśli nie znasz nazwiska? Wtedy policja wiesza list gończy: „wysoki, wąsy, czapka z daszkiem, ostatnio widziany w Biedronce”. To nie jest konkretny człowiek. To opis, do którego pasuje wielu ludzi.

To jest wyrażenie regularne. Po angielsku regular expression, w skrócie regex. Opis tekstu zamiast samego tekstu. „Od jednej do trzech cyfr, kropka, i tak cztery razy” to list gończy na adres IP.

Uwaga, pułapka. Pamiętasz gwiazdkę z lekcji 5? Tamta gwiazdka to glob: działa na nazwach plików i rozwija ją Bash. W regeksach te same znaczki znaczą co innego. To jak „pilot” w samolocie i „pilot” od telewizora. Słowo to samo, a nie chcesz ich pomylić.

Wzór zawsze wkładamy w apostrofy. To nasza koperta: Bash nie zagląda do środka, nie rozwija gwiazdek ani dolarów, tylko oddaje całość grepowi.

Kropka: dowolny znak

printf 'kot\nkit\nkat\nk.t\nkoty\nkt\n' > slowa.txt
grep 'k.t' slowa.txt
kot
kit
kat
k.t
koty

Kropka znaczy „dowolny jeden znak”. Joker w kartach. Dlatego pasuje kot, kit, kat, a nawet k.t. Pasuje też koty, bo grep szuka wzoru w dowolnym miejscu linijki, a „kot” siedzi w „koty”. Nie pasuje kt, bo między k i t musi być dokładnie jeden znak, a nie zero.

A jeśli chcesz prawdziwą kropkę? Stawiasz przed nią ukośnik wsteczny:

grep 'k\.t' slowa.txt
k.t

Ukośnik mówi: „następny znak traktuj dosłownie, nie jako joker”. W haku to właśnie \. między liczbami adresu IP.

Kotwice: ^ i $

^ to początek linijki, $ to koniec. Jak kotwica, która przybija wzór do brzegu.

grep '^kot$' slowa.txt
kot

Teraz tylko sam kot. Bez kotów, bez kotletów.

Najbardziej adminowe zastosowanie: plik konfiguracyjny bez komentarzy i pustych linijek. Plik /etc/login.defs ma u mnie 395 linijek, a ustawień jest w nim 37. Reszta to komentarze. Jak umowa z bankiem: trzy zdania treści i cztery strony gwiazdek.

grep -Ev '^[[:space:]]*(#|$)' /etc/login.defs
MAIL_DIR        /var/mail
FAILLOG_ENAB		yes
LOG_UNKFAIL_ENAB	no
LOG_OK_LOGINS		no
...

Czytamy od lewej: początek linijki, potem dowolnie dużo białych znaków, potem albo krzyżyk, albo od razu koniec linijki. -v odwraca: pokaż wszystko, co NIE jest komentarzem ani pustką. Zapamiętaj tę linijkę, użyjesz jej setki razy.

Nawiasy kwadratowe: jeden z listy

grep 'k[ao]t' slowa.txt
kot
kat
koty

[ao] to „dokładnie jeden znak: a albo o”. Jak bramkarz z listą gości. Z daszkiem w środku, [^o], lista się odwraca: „jeden znak, byle nie o”.

Zakresy: [0-9] to cyfra, [a-z] mała litera. A tu ważna rzecz dla nas. [a-z] nie zna polskich liter. Do tego są klasy w podwójnych nawiasach:

echo 'Łódź Kraków' | grep -oE '[[:upper:]][[:lower:]]+'
Łódź
Kraków

[[:digit:]] to cyfra, [[:alpha:]] litera, [[:space:]] biały znak, [[:upper:]] i [[:lower:]] wielka i mała litera. Z ustawionym językiem UTF-8 łapią też Ł i ó. Wyglądają jak emotka z dwoma parami okularów, ale działają wszędzie: w grepie, sedzie, awku i na Macu.

Ile razy: *, +, ? i {}

Plik gol.txt to komentarz sportowy: gol, gool, gooool i smutne gl.

grep -E 'go+l' gol.txt
gol
gool
gooool

Te znaczki zawsze mówią coś o znaku, który stoi tuż przed nimi:

  • o* to zero lub więcej o. Pasuje nawet gl.
  • o+ to jedno lub więcej.
  • o? to zero albo jedno: gol i gl.
  • o{3,} to co najmniej trzy. Tylko prawdziwy komentator: gooool.
  • {1,3} to od jednego do trzech. W haku [0-9]{1,3} to „od jednej do trzech cyfr”.

A co to za -E? Grep ma dwa dialekty. Podstawowy, BRE, i rozszerzony, ERE. W podstawowym plus, pytajnik, klamry, nawiasy okrągłe i pionowa kreska są zwykłymi znakami:

grep 'go+l' gol.txt; echo "kod: $?"
kod: 1

Nic nie znalazł, bo szukał dosłownie napisu „go+l”. W podstawowym dialekcie trzeba by pisać go\+l, a adres IP wyglądałby tak: [0-9]\{1,3\}\(\.[0-9]\{1,3\}\)\{3\}. To już nie kot na klawiaturze, to całe schronisko. Dlatego moja rada: zawsze grep -E. Stare egrep to to samo, ale dziś pisze się grep -E.

Alternatywa i grupy: | i ()

Pionowa kreska w regeksie to „albo”. Uwaga, w apostrofach to nie jest rura, bo koperta chroni ją przed Bashem.

grep -oE '(Accepted|Failed) (password|publickey)' auth.log | sort | uniq -c
      1 Accepted password
      1 Accepted publickey
      6 Failed password

Nawiasy okrągłe robią grupę. Grupa to paczka, na którą możesz nałożyć licznik. W haku (\.[0-9]{1,3}){3} znaczy: „kropka i od jednej do trzech cyfr, i to wszystko trzy razy”.

grep -o: nożyczki zamiast zakreślacza

Zwykły grep to zakreślacz: pokazuje całą linijkę, w której coś znalazł. -o to nożyczki: wycina tylko pasujący kawałek, każdy w osobnej linijce. Jeśli w jednej linijce są trzy pasujące kawałki, dostajesz trzy linijki.

Rozbierzmy hak:

  1. grep 'Failed' auth.log zostawia tylko nieudane logowania.
  2. grep -oE '[0-9]{1,3}(\.[0-9]{1,3}){3}' wycina z nich same adresy IP.
  3. sort | uniq -c | sort -rn to nasza znana trójka z lekcji 7: ranking.

Ten sam trik na maile:

grep -oE '[[:alnum:]._-]+@[[:alnum:].-]+\.[a-z]+' maile.txt
eryk@miquido.com
ania@example.org
biuro@example.org

Uczciwie: ten wzór na IP złapie też 999.999.999.999. List gończy to nie badanie DNA. Do wyciągania adresów z logu wystarczy. Do sprawdzania, czy ktoś wpisał poprawny adres, potrzebujesz czegoś ostrzejszego.

Regex w Bashu: [[ =~ ]] i BASH_REMATCH

Grep szuka w plikach. A czasem chcesz sprawdzić jedną zmienną w skrypcie: „czy to wygląda jak data?”. Do tego jest nasz bramkarz if z listem gończym w ręku: operator =~ w podwójnych nawiasach kwadratowych.

#!/bin/bash
data="2026-11-03"
wzor='^([0-9]{4})-([0-9]{2})-([0-9]{2})$'
if [[ $data =~ $wzor ]]; then
    echo "rok=${BASH_REMATCH[1]} mies=${BASH_REMATCH[2]} dzien=${BASH_REMATCH[3]}"
fi
rok=2026 mies=11 dzien=03

Bash używa tu dialektu rozszerzonego, tego samego co grep -E. A po udanym dopasowaniu wypełnia tablicę BASH_REMATCH. Szuflada z przegródkami z lekcji 42: przegródka 0 to cały dopasowany kawałek, przegródka 1 to pierwsza grupa w nawiasach, 2 to druga i tak dalej.

Dwie zasady, które oszczędzą Ci godzinę płaczu:

  • Wzór trzymaj w zmiennej, a w [[ ]] pisz $wzor bez cudzysłowów.
  • Prawej strony =~ nie bierz w cudzysłów. Cudzysłów zamienia list gończy w dosłowny napis.

A teraz prawdziwa robota. Skrypt, który czyta log i liczy nieudane próby na każdy adres. Użyjemy słownika z lekcji 43, czyli szatni z nazwiskami:

#!/bin/bash
# Liczy nieudane logowania SSH per adres IP
plik=${1:-auth.log}
wzor='Failed password for (invalid user )?([^ ]+) from ([0-9.]+)'
declare -A proby

while IFS= read -r linia; do
    if [[ $linia =~ $wzor ]]; then
        ip=${BASH_REMATCH[3]}
        (( proby[$ip]++ ))
    fi
done < "$plik"

for ip in "${!proby[@]}"; do
    printf '%-16s %3d\n' "$ip" "${proby[$ip]}"
done
198.51.100.7       2
192.0.2.10         1
203.0.113.45       3

Grupa (invalid user )? jest opcjonalna, więc łapie i for root, i for invalid user admin. [^ ]+ to „ciąg znaków bez spacji”, czyli nazwa użytkownika. Kolejność adresów jest losowa, bo szatnia nie układa wieszaków alfabetycznie. Jak chcesz ranking, dorzuć na końcu | sort -k2 -rn.

Jak to działa pod spodem

Grep nie czyta wzoru za każdym razem od nowa. Najpierw kompiluje go w automat, czyli maszynę stanów. Wyobraź sobie planszę do gry: każdy znak tekstu przesuwa pionek na inne pole. Jeśli pionek dojdzie do mety, linijka pasuje. Dzięki temu grep przelatuje gigabajty logów, nie cofając się co chwilę.

Bash nie ma własnego silnika regeksów. Do =~ woła funkcje regcomp i regexec z biblioteki systemowej, czyli tej samej, z której korzystają inne programy w C. Dlatego to, co działa w [[ =~ ]], zależy trochę od systemu. \d na cyfrę z innych języków programowania tu nie istnieje. Pisz [0-9] albo [[:digit:]] i śpij spokojnie.

Kod wyjścia: 0, kiedy pasuje, 1, kiedy nie pasuje, i 2, kiedy sam wzór jest zepsuty, na przykład niezamknięty nawias.

[macOS] Grep na Macu to inna implementacja niż na Linuksie. -E, -o, -w i klasy [[:digit:]] działają tak samo, ale nie ma -P. Jeśli przepis z internetu ma grep -P, na Macu zainstaluj GNU grep z Homebrew (brew install grep, komenda nazywa się wtedy ggrep). [[ =~ ]] i BASH_REMATCH działają nawet w starym Bashu 3.2 z Maca.

Ściąga flag

KomendaFlaga / zapisCo robi
grep-Erozszerzone regeksy: plus, pytajnik, klamry, nawiasy i „albo” działają bez ukośników
grep-owypisuje tylko pasujące kawałki, każdy w osobnej linijce
grep-vodwraca: linijki, które NIE pasują
grep-iignoruje wielkość liter
grep-wdopasowuje całe słowa
grep-cliczy pasujące linijki
grep-Pwyrażenia w stylu Perla (tylko GNU grep)
regex.dowolny jeden znak
regex^ / $początek / koniec linijki
regexjeden z / żaden z tych znaków
regexcyfra / litera / biały znak
regex* / + / ?0 lub więcej / 1 lub więcej / 0 albo 1
regex{n} / {n,m} / {n,}dokładnie n / od n do m / co najmniej n
regex( ) i pionowa kreskagrupa i „albo”
regex\.dosłowna kropka (ukośnik wyłącza specjalne znaczenie)
Bash[[ $x =~ $wzor ]]sprawdza zmienną regeksem, kod 0 / 1 / 2
Bash${BASH_REMATCH[0]} / [1]cały dopasowany kawałek / pierwsza grupa

⚠ Typowy błąd

x=123
if [[ $x =~ "^[0-9]+$" ]]; then echo "liczba"; else echo "nie liczba"; fi
nie liczba

123 to nie liczba? Bash nie zwariował. Cudzysłów po prawej stronie =~ mówi: „szukaj dosłownie napisu z daszkiem, nawiasami i dolarem”. Bash szukał w „123” znaczków ^[0-9]+$ i oczywiście ich nie znalazł. Wysłałeś bramkarzowi zdjęcie listu gończego, a on szuka człowieka, który wygląda jak kartka papieru.

ShellCheck od razu to widzi:

SC2076 (warning): Remove quotes from right-hand side of =~ to match as a regex rather than literally.

Naprawa: wzór do zmiennej, zmienna bez cudzysłowów.

wzor='^[0-9]+$'
if [[ $x =~ $wzor ]]; then echo "liczba"; fi

I drugi klasyk: grep 'go+l' bez -E. Kod 1, zero wyników, a Ty patrzysz w plik, gdzie stoi wielkie „gooool”. Plus bez -E to zwykły plus.

✎ Zadanie

  1. Z pliku auth.log z tej lekcji (albo z prawdziwego /var/log/auth.log [Ubuntu] lub /var/log/secure [RHEL], przez sudo) wypisz nazwy użytkowników, na które ktoś próbował się zalogować nieudanie, razem z liczbą prób. Od najczęstszego. Jedna linijka z grep -oE.
  2. Napisz skrypt data.sh, który dostaje jako argument datę w formacie RRRR-MM-DD, sprawdza ją przez [[ =~ ]] i wypisuje „Rok: …, miesiąc: …, dzień: …” z BASH_REMATCH. Przy złym formacie ma wypisać błąd na stderr i zakończyć się kodem 1.
Pokaż rozwiązanie

Część pierwsza: kto był celem nieudanych logowań.

grep -oE 'Failed password for (invalid user )?[^ ]+' auth.log | grep -oE '[^ ]+$' | sort | uniq -c | sort -rn
      3 root
      2 admin
      1 eryk

Pierwszy grep wycina kawałek od „Failed password for” do nazwy użytkownika. Drugi bierze z niego ostatnie słowo: [^ ]+$ to „ciąg znaków bez spacji, a zaraz potem koniec linijki”. Root na pierwszym miejscu. Jak zawsze. Root to najpopularniejszy użytkownik wśród ludzi, którzy nie powinni go znać.

Część druga, skrypt data.sh:

#!/bin/bash
# Rozbija datę RRRR-MM-DD na części
wzor='^([0-9]{4})-([0-9]{2})-([0-9]{2})$'
if [[ $1 =~ $wzor ]]; then
    echo "Rok: ${BASH_REMATCH[1]}, miesiąc: ${BASH_REMATCH[2]}, dzień: ${BASH_REMATCH[3]}"
else
    echo "Błąd: '$1' to nie jest data w formacie RRRR-MM-DD" >&2
    exit 1
fi
$ ./data.sh 2026-11-03
Rok: 2026, miesiąc: 11, dzień: 03
$ ./data.sh 3.11.2026; echo "kod: $?"
Błąd: '3.11.2026' to nie jest data w formacie RRRR-MM-DD
kod: 1

Wzór w zmiennej, zmienna bez cudzysłowów, błąd na drugi głośnik przez >&2. Uczciwie: ten skrypt przepuści też 2026-13-45. Format się zgadza, kalendarz płacze. Sprawdzanie, czy taki dzień w ogóle istnieje, to już robota dla date -d, a nie dla listu gończego.

Co dalej

Umiesz już znaleźć tekst listem gończym. W następnej lekcji zaczniemy go zmieniać: sed, czyli edytor, który poprawia pliki konfiguracyjne bez otwierania ich, nawet na stu serwerach naraz.