Skip to content

Instantly share code, notes, and snippets.

@xakepp35
Created October 9, 2025 07:40
Show Gist options
  • Select an option

  • Save xakepp35/164470ecd136ba4fabe246eaf0e48a40 to your computer and use it in GitHub Desktop.

Select an option

Save xakepp35/164470ecd136ba4fabe246eaf0e48a40 to your computer and use it in GitHub Desktop.
Проверка и (опционно) исправление набора рекомендаций по харденингу Linux (FSTEC-like).
#!/usr/bin/env bash
# fstec_harden.sh
# Проверка и (опционно) исправление набора рекомендаций по харденингу Linux (FSTEC-like).
# Usage:
# ./fstec_harden.sh # только проверки
# ./fstec_harden.sh --apply # применить безопасные исправления
# ./fstec_harden.sh --apply --force # применить и рискованные исправления
#
# Автор: ChatGPT
# Дата: 2025-10-09
# Лицензия MIT
set -euo pipefail
LOGFILE="/var/log/fstec_harden.log"
[ -w "$(dirname "$LOGFILE")" ] || LOGFILE="./fstec_harden.log"
APPLY=0
FORCE=0
# Colors
GREEN="\e[32m"
YELLOW="\e[33m"
RED="\e[31m"
RESET="\e[0m"
INFO="\e[36m"
timestamp() { date -Iseconds; }
log() {
echo -e "[$(timestamp)] $*" | tee -a "$LOGFILE"
}
# status helpers
pass() { log -e "${GREEN}PASS${RESET} $*"; }
warn() { log -e "${YELLOW}WARN${RESET} $*"; }
err() { log -e "${RED}ERR${RESET} $*"; }
# utils
require_root() {
if [ "$EUID" -ne 0 ]; then
err "Для этой операции требуются root-привилегии. Перезапустите скрипт как root."
exit 2
fi
}
backup_file() {
local f="$1"
if [ -e "$f" ]; then
local b="${f}.fstecbak.$(date +%Y%m%d%H%M%S)"
cp -a -- "$f" "$b"
log "backup: $f -> $b"
fi
}
# parse args
while (( "$#" )); do
case "$1" in
--apply) APPLY=1; shift ;;
--force) FORCE=1; shift ;;
--help|-h) echo "Usage: $0 [--apply] [--force]"; exit 0 ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done
log "=== START fstec_harden run (apply=$APPLY force=$FORCE) ==="
######### Checks & fixes #########
########################################
# 2.1.1 Check accounts with empty passwords and /etc/shadow perms
check_shadow_empty_passwords() {
log "Check: empty-password accounts and /etc/shadow permissions"
local shadow="/etc/shadow"
if [ ! -r "$shadow" ]; then
err "/etc/shadow недоступен для чтения. Проверьте права."
return
fi
# list accounts with empty password field (second field empty)
local empty_accounts
empty_accounts=$(awk -F: '($2=="" ) {print $1}' /etc/shadow || true)
if [ -n "$empty_accounts" ]; then
warn "Найдены учётные записи с пустым паролем: $(echo $empty_accounts | tr '\n' ' ')"
if [ "$APPLY" -eq 1 ]; then
require_root
for u in $empty_accounts; do
if [ "$FORCE" -eq 1 ]; then
log "Locking account $u (passwd -l)"
passwd -l "$u" 2>>"$LOGFILE" || warn "Не удалось заблокировать $u"
else
warn "Чтобы заблокировать аккаунты с пустыми паролями, перезапустить с --apply --force"
fi
done
fi
else
pass "Пустых паролей в /etc/shadow не найдено"
fi
# permissions
local perms
perms=$(stat -c "%a %n" "$shadow")
if [ "$(stat -c %a $shadow)" -gt 600 ]; then
warn "/etc/shadow имеет права $(stat -c %a $shadow) — рекомендуется 600"
if [ "$APPLY" -eq 1 ]; then
require_root
chmod 600 "$shadow" && pass "Установлены права 600 на $shadow"
fi
else
pass "/etc/shadow права OK: $(stat -c %a $shadow)"
fi
}
########################################
# 2.1.2 Disable PermitRootLogin over SSH
check_sshd_root_login() {
log "Check: PermitRootLogin in sshd_config"
local f="/etc/ssh/sshd_config"
if [ ! -f "$f" ]; then
warn "sshd_config не найден ($f) — пропускаем"
return
fi
local cur
cur=$(sshd -T 2>/dev/null | awk '/permitrootlogin/ {print $2}' || true)
if [ -z "$cur" ]; then
# fallback to file parse
cur=$(grep -i '^PermitRootLogin' "$f" 2>/dev/null | tail -n1 | awk '{print $2}' || true)
fi
if [ "$cur" = "no" ]; then
pass "PermitRootLogin уже выключен ($cur)"
else
warn "PermitRootLogin = ${cur:-unspecified} — рекомендуется no"
if [ "$APPLY" -eq 1 ]; then
require_root
backup_file "$f"
# Set or add
if grep -qi '^PermitRootLogin' "$f"; then
sed -ri 's/^(#\s*)?PermitRootLogin\s+.*/PermitRootLogin no/' "$f"
else
echo -e "\n# hardened by fstec_harden\nPermitRootLogin no" >> "$f"
fi
systemctl restart sshd 2>>"$LOGFILE" && pass "sshd restarted and PermitRootLogin set to no"
fi
fi
}
########################################
# 2.2.1 PAM wheel for su
check_pam_su_wheel() {
log "Check: PAM wheel for /bin/su"
local f="/etc/pam.d/su"
if [ ! -f "$f" ]; then
warn "$f not found — возможно systemd-based distro without su. Пропускаем"
return
fi
if grep -qE '^\s*auth\s+required\s+pam_wheel.so\b' "$f"; then
pass "PAM wheel for su present"
else
warn "PAM wheel not present in $f (auth required pam_wheel.so use_uid recommended)"
if [ "$APPLY" -eq 1 ]; then
require_root
backup_file "$f"
# add line near top
sed -i '1i# added by fstec_harden\nauth required pam_wheel.so use_uid' "$f"
pass "Добавлена строка в $f: auth required pam_wheel.so use_uid"
fi
fi
# check wheel group exists
if getent group wheel >/dev/null; then
pass "Группа wheel существует"
else
warn "Группа wheel отсутствует"
if [ "$APPLY" -eq 1 ]; then
require_root
groupadd wheel && pass "Создана группа wheel"
fi
fi
}
########################################
# 2.2.2 sudoers review (non destructive)
check_sudoers() {
log "Check: /etc/sudoers and /etc/sudoers.d"
if [ -f /etc/sudoers ]; then
# look for unrestricted ALL=(ALL) NOPASSWD
if grep -E '^[^#]*ALL\s*=\s*\(ALL:ALL\)\s*ALL' /etc/sudoers >/dev/null 2>&1 || grep -E 'NOPASSWD' /etc/sudoers >/dev/null 2>&1; then
warn "Найдены потенциально широкие правила в /etc/sudoers (ALL / NOPASSWD). Рекомендуется пересмотреть."
else
pass "/etc/sudoers выглядит аккуратно"
fi
else
warn "/etc/sudoers не найден"
fi
# scan sudoers.d
if [ -d /etc/sudoers.d ]; then
local wide
wide=$(grep -R --line-number -E 'NOPASSWD|ALL\s*=\s*\(ALL:ALL\)\s*ALL' /etc/sudoers.d 2>/dev/null || true)
if [ -n "$wide" ]; then
warn "Найдены широкие правила в /etc/sudoers.d: $(echo "$wide" | tr '\n' ';')"
else
pass "/etc/sudoers.d проверки пройдены"
fi
fi
if [ "$APPLY" -eq 1 ]; then
log "Прямое редактирование sudoers небезопасно — предлагаем отдельный файл в /etc/sudoers.d/ с ограничениями"
if [ "$FORCE" -eq 1 ]; then
require_root
local snippet="/etc/sudoers.d/fstec_restrict"
backup_file "$snippet"
cat > "$snippet" <<'EOF'
# fragment created by fstec_harden - restrict example (edit to your needs)
# Пример: дать user1 право запускать только /bin/systemctl
# user1 ALL=(ALL) /bin/systemctl
EOF
chmod 440 "$snippet"
pass "Создан шаблон $snippet — отредактируйте под свои нужды"
else
warn "Для создания шаблона sudoers используйте --apply --force"
fi
fi
}
########################################
# 2.3.* File system permissions
check_file_permissions() {
log "Check: базовые права на /etc/passwd, /etc/group, /etc/shadow"
for f in /etc/passwd /etc/group /etc/shadow; do
if [ -e "$f" ]; then
cur=$(stat -c "%a %U:%G %n" "$f")
log "$cur"
else
warn "Не найден $f"
fi
done
# verify recommended perms
if [ -e /etc/passwd ] && [ "$(stat -c %a /etc/passwd)" -ne 644 ]; then
warn "/etc/passwd рекомендуется 644"
if [ "$APPLY" -eq 1 ]; then
require_root
chmod 644 /etc/passwd && pass "chmod 644 /etc/passwd"
fi
else
pass "/etc/passwd права OK"
fi
if [ -e /etc/group ] && [ "$(stat -c %a /etc/group)" -ne 644 ]; then
warn "/etc/group рекомендуется 644"
if [ "$APPLY" -eq 1 ]; then
require_root
chmod 644 /etc/group && pass "chmod 644 /etc/group"
fi
else
pass "/etc/group права OK"
fi
if [ -e /etc/shadow ] && [ "$(stat -c %a /etc/shadow)" -ne 600 ]; then
warn "/etc/shadow рекомендуется 600"
if [ "$APPLY" -eq 1 ]; then
require_root
chmod 600 /etc/shadow && pass "chmod 600 /etc/shadow"
fi
else
pass "/etc/shadow права OK"
fi
}
# 2.3.2 - find executables with world-writable bit or in writable dirs
check_exec_writable() {
log "Check: исполняемые файлы и библиотеки доступны для записи группой/прочими?"
# Find executables that are writable by group/other
warn_files=$(find / -xdev -type f \( -perm -002 -o -perm -020 \) -exec ls -ld {} + 2>/dev/null || true)
if [ -n "$warn_files" ]; then
warn "Найдены файлы, доступные для записи group/other (показаны частично)."
log "$(echo "$warn_files" | head -n 20)"
if [ "$APPLY" -eq 1 ]; then
require_root
# be conservative: change only for common bin dirs
for d in /bin /sbin /usr/bin /usr/sbin /usr/local/bin /usr/local/sbin; do
if [ -d "$d" ]; then
find "$d" -type f \( -perm -002 -o -perm -020 \) -exec chmod go-w {} \; 2>/dev/null || true
fi
done
pass "Поправлены права записи для /bin,/usr/bin и т.п. (консервативно)"
fi
else
pass "Нет исполняемых файлов с правом записи для group/other на проанализированных FS"
fi
}
# 2.3.3 cron files permissions
check_cron_files() {
log "Check: права на системные cron файлы"
if [ -e /etc/crontab ]; then
if [ "$(stat -c %a /etc/crontab)" -gt 644 ]; then
warn "/etc/crontab имеет слишком открытые права $(stat -c %a /etc/crontab)"
if [ "$APPLY" -eq 1 ]; then
require_root
chmod go-wx /etc/crontab && pass "chmod go-wx /etc/crontab"
fi
else
pass "/etc/crontab права OK"
fi
fi
if [ -d /etc/cron.d ]; then
local bad
bad=$(find /etc/cron.d -type f ! -perm -go-wx 2>/dev/null || true)
if [ -n "$bad" ]; then
warn "Некоторые файлы в /etc/cron.d имеют открытые права"
if [ "$APPLY" -eq 1 ]; then
require_root
find /etc/cron.d -type f -exec chmod go-wx {} \; && pass "Исправлены права в /etc/cron.d"
fi
else
pass "/etc/cron.d права OK"
fi
fi
# user crons
if [ -d /var/spool/cron ]; then
if [ "$(find /var/spool/cron -type f -exec stat -c '%a %n' {} + 2>/dev/null | awk '{print $1}' | grep -E '^[0-9]+$' | sort -u | grep -v '^600$' || true)" ]; then
warn "Есть пользовательские cron-файлы с нестандартными правами"
if [ "$APPLY" -eq 1 ]; then
require_root
find /var/spool/cron -type f -exec chmod 600 {} \; 2>/dev/null && pass "Установлены права 600 на user crons"
fi
else
pass "Права на пользовательские cron OK (либо нет /var/spool/cron)"
fi
fi
}
# 2.3.9 SUID/SGID audit
check_suid_sgid() {
log "Check: SUID/SGID приложения"
local suid_list
suid_list=$(find / -perm -4000 -o -perm -2000 -type f 2>/dev/null || true)
if [ -z "$suid_list" ]; then
pass "SUID/SGID не найдены"
return
fi
log "Найдено SUID/SGID (первые 40):"
echo "$suid_list" | head -n40 | tee -a "$LOGFILE"
# whitelist common bins that usually have SUID
local WHITELIST="/bin/su|/bin/ping|/usr/bin/sudo|/usr/bin/chfn|/usr/bin/chsh|/sbin/mount.*|/sbin/umount.*|/usr/bin/passwd"
local suspect
suspect=$(echo "$suid_list" | grep -Ev "$WHITELIST" || true)
if [ -n "$suspect" ]; then
warn "Есть SUID/SGID вне белого списка (показаны первые 20):"
echo "$suspect" | head -n20 | tee -a "$LOGFILE"
if [ "$APPLY" -eq 1 ]; then
require_root
if [ "$FORCE" -eq 1 ]; then
echo "$suspect" | while read -r f; do
[ -z "$f" ] && continue
chmod -s "$f" && log "suid/sgid снят: $f"
done
pass "Сняты SUID/SGID для подозрительных файлов (force)"
else
warn "Чтобы автоматически снять SUID/SGID для подозрительных файлов, запустите с --apply --force"
fi
fi
else
pass "SUID/SGID соответствуют ожидаемому списку"
fi
}
# 2.3.10/11 home dirs and dotfiles
check_home_permissions() {
log "Check: права на домашние директории и скрытые файлы"
local users
users=$(awk -F: '($3>=1000 && $1!="nobody"){print $1 ":" $6}' /etc/passwd || true)
if [ -z "$users" ]; then
warn "Не найдено обычных пользователей (UID>=1000) — пропускаем"
return
fi
while IFS=: read -r user homedir; do
[ -z "$homedir" ] && continue
if [ -d "$homedir" ]; then
local hperm
hperm=$(stat -c %a "$homedir")
if [ "$hperm" -ne 700 ]; then
warn "Домашняя директория $homedir пользователя $user имеет права $hperm (рекомендуется 700)"
if [ "$APPLY" -eq 1 ]; then
require_root
chmod 700 "$homedir" && pass "chmod 700 $homedir"
fi
else
pass "$homedir ($user) права OK"
fi
# dot-files
local df
df=$(find "$homedir" -maxdepth 1 -name ".*" -type f 2>/dev/null || true)
if [ -n "$df" ]; then
echo "$df" | while read -r f; do
[ -z "$f" ] && continue
if [ "$(stat -c %a "$f")" -gt 700 ]; then
warn "Файл $f слишком открыт ($(stat -c %a "$f"))"
if [ "$APPLY" -eq 1 ]; then
require_root
chmod go-rwx "$f" && log "chmod go-rwx $f"
fi
fi
done
pass "$user: проверены скрытые файлы"
fi
fi
done <<< "$users"
}
# 2.4.* kernel protection sysctls
apply_sysctl_kv() {
local key="$1" val="$2" desc="$3"
local cur
cur=$(sysctl -n "$key" 2>/dev/null || echo "")
if [ "$cur" = "$val" ]; then
pass "$key = $val"
else
warn "$key = ${cur:-unset} (рекомендуется $val) -- $desc"
if [ "$APPLY" -eq 1 ]; then
require_root
sysctl -w "$key=$val" >>"$LOGFILE" 2>&1 || warn "sysctl -w $key failed"
# persist to /etc/sysctl.d/99-fstec.conf
local conf="/etc/sysctl.d/99-fstec.conf"
backup_file "$conf"
grep -q "^$key" "$conf" 2>/dev/null && sed -ri "s|^$key.*|$key = $val|" "$conf" || echo "$key = $val" >> "$conf"
pass "Установлен $key=$val и сохранён в $conf"
fi
fi
}
check_sysctls() {
log "Check: kernel/sysctl hardening"
apply_sysctl_kv "kernel.dmesg_restrict" "1" "ограничить dmesg"
apply_sysctl_kv "kernel.kptr_restrict" "2" "скрыть kernel pointers"
apply_sysctl_kv "net.core.bpf_jit_harden" "2" "bpf jit harden"
apply_sysctl_kv "kernel.perf_event_paranoid" "3" "ограничить perf events"
apply_sysctl_kv "kernel.kexec_load_disabled" "1" "запретить kexec_load"
apply_sysctl_kv "user.max_user_namespaces" "0" "ограничить user namespaces"
apply_sysctl_kv "kernel.unprivileged_bpf_disabled" "1" "запретить bpf"
apply_sysctl_kv "vm.unprivileged_userfaultfd" "0" "запретить userfaultfd"
apply_sysctl_kv "dev.tty.ldisc_autoload" "0" "запрет автозагрузки line discipline modules"
apply_sysctl_kv "vm.mmap_min_addr" "4096" "минимальный mmap адрес"
apply_sysctl_kv "kernel.randomize_va_space" "2" "ASLR"
apply_sysctl_kv "kernel.yama.ptrace_scope" "3" "ограничить ptrace"
apply_sysctl_kv "fs.protected_symlinks" "1" "защита symlinks"
apply_sysctl_kv "fs.protected_hardlinks" "1" "защита hardlinks"
apply_sysctl_kv "fs.protected_fifos" "2" "защита fifos"
apply_sysctl_kv "fs.protected_regular" "2" "защита обычных файлов"
apply_sysctl_kv "fs.suid_dumpable" "0" "запрет core dump для suid"
}
# 2.4.* and 2.5.* GRUB kernel cmdline flags (append)
check_grub_cmdline() {
log "Check: GRUB kernel command line options (append recommended params)"
# recommended kernel params list
local want=( "init_on_alloc=1" "slab_nomerge" "iommu=force" "iommu.strict=1" "iommu.passthrough=0" "randomize_kstack_offset=1" "mitigations=auto,nosmt" "vsyscall=none" "debugfs=off" "tsx=off" )
# get current cmdline from /proc/cmdline
local cur
cur=$(cat /proc/cmdline 2>/dev/null || true)
local missing=()
for p in "${want[@]}"; do
if ! echo "$cur" | grep -qw "$p"; then
missing+=("$p")
fi
done
if [ "${#missing[@]}" -eq 0 ]; then
pass "GRUB/kernel cmdline уже содержит рекомендуемые параметры"
return
fi
warn "Некоторые kernel options отсутствуют в текущем cmdline: ${missing[*]}"
if [ "$APPLY" -eq 1 ]; then
require_root
# attempt to update /etc/default/grub or /etc/default/grub.d/*
local grubfile="/etc/default/grub"
if [ ! -f "$grubfile" ]; then
warn "$grubfile не найден — возможно другая система (grub2 not present). Пропускаем автоматическую правку."
return
fi
backup_file "$grubfile"
# Append missing to GRUB_CMDLINE_LINUX_DEFAULT
local curval
curval=$(grep -E "^GRUB_CMDLINE_LINUX_DEFAULT=" "$grubfile" | head -n1 || true)
if [ -z "$curval" ]; then
# add new
echo "GRUB_CMDLINE_LINUX_DEFAULT=\"${missing[*]}\"" >> "$grubfile"
else
# append
# strip trailing quote then append if not present
sed -ri "s~^GRUB_CMDLINE_LINUX_DEFAULT=(\"?)(.*)(\"?)$~GRUB_CMDLINE_LINUX_DEFAULT=\"\2 ${missing[*]}\"~" "$grubfile"
fi
# Update grub config depending on distro
if command -v update-grub >/dev/null 2>&1; then
update-grub >>"$LOGFILE" 2>&1 && pass "update-grub выполнен, kernel options добавлены (перезагрузка нужна)"
elif command -v grub2-mkconfig >/dev/null 2>&1 && [ -d /boot/efi -o -d /boot ]; then
grub2-mkconfig -o /boot/grub2/grub.cfg >>"$LOGFILE" 2>&1 && pass "grub2-mkconfig выполнен"
else
warn "Не удалось автоматически обновить grub — выполните вручную: проверьте $grubfile и запустите update-grub или grub2-mkconfig"
fi
if [ "$FORCE" -ne 1 ]; then
warn "Для внесения per-boot опций требуются перезагрузка и --force при следующем запуске (если хотите рискованные правки)."
fi
fi
}
# 2.6.* userspace protections (ptrace etc handled in sysctl)
# Additional small checks: core dumps
check_core_dumps() {
log "Check: core dumps (fs.suid_dumpable already проверён)"
local cur
cur=$(ulimit -c 2>/dev/null || echo "unknown")
if [ "$cur" = "0" ]; then
pass "core dumps disabled (ulimit -c = 0)"
else
warn "ulimit -c = $cur (кор-дамп включён). Рекомендуется 0."
if [ "$APPLY" -eq 1 ]; then
require_root
if grep -q "^*.*hard.*core" /etc/security/limits.conf 2>/dev/null; then
pass "limits.conf уже содержит правило для core"
else
echo "* hard core 0" >> /etc/security/limits.conf
pass "Добавлено правило * hard core 0 в /etc/security/limits.conf"
fi
fi
fi
}
########################################
# Run all checks
main() {
check_shadow_empty_passwords
check_sshd_root_login
check_pam_su_wheel
check_sudoers
check_file_permissions
check_exec_writable
check_cron_files
check_suid_sgid
check_home_permissions
check_sysctls
check_grub_cmdline
check_core_dumps
log "=== FINISHED checks. См. лог: $LOGFILE ==="
# summary (simple)
echo -e "\nSummary: лог записан в $LOGFILE"
echo -e "${GREEN}PASS${RESET} — пункты, которые выглядят OK"
echo -e "${YELLOW}WARN${RESET} — несоответствия, требующие внимания (часто безопасно исправить)"
echo -e "${RED}ERR${RESET} — критичные проблемы/ошибки\n"
log "=== END fstec_harden run ==="
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment