feat: release 2.1.8-SAC — security phase 1 and SAC-first update hardening

Updater applies chmod/chown on config, spool and state paths on every run.
SAC is the primary update path; optional cron uses the same behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-07-08 09:44:24 +10:00
parent ec0857b8f2
commit 2048162057
11 changed files with 618 additions and 243 deletions
+70 -11
View File
@@ -7,9 +7,13 @@ IFS=$'\n\t'
# ============================================
CONFIG_FILE="/etc/ssh-monitor.conf"
SSH_MONITOR_VERSION="2.1.6-SAC"
SSH_MONITOR_VERSION="2.1.8-SAC"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$SCRIPT_DIR/ssh-monitor-perms.sh" ]; then
# shellcheck source=ssh-monitor-perms.sh
source "$SCRIPT_DIR/ssh-monitor-perms.sh"
fi
if [ -f "$SCRIPT_DIR/sac-client.sh" ]; then
# shellcheck source=sac-client.sh
source "$SCRIPT_DIR/sac-client.sh"
@@ -39,6 +43,8 @@ SAC_SPOOL_FLUSH_MAX_FILES="50"
SAC_SPOOL_MAX_AGE_HOURS="72"
SAC_SEND_HEARTBEAT="1"
SAC_FALLBACK_FAILURES="5"
SAC_TLS_INSECURE="0"
CONFIG_STRICT_PERMS="0"
# Почта (SMTP через python3). Нужны MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO.
MAIL_SMTP_HOST=""
@@ -229,6 +235,8 @@ load_config() {
local cfg_sac_timeout_sec="${SAC_TIMEOUT_SEC:-45}"
local cfg_sac_spool_flush_max="${SAC_SPOOL_FLUSH_MAX_FILES:-50}"
local cfg_sac_spool_max_age_h="${SAC_SPOOL_MAX_AGE_HOURS:-72}"
local cfg_sac_tls_insecure="${SAC_TLS_INSECURE:-0}"
local cfg_config_strict_perms="${CONFIG_STRICT_PERMS:-0}"
local cfg_mail_smtp_host="${MAIL_SMTP_HOST:-}"
local cfg_mail_smtp_port="${MAIL_SMTP_PORT:-587}"
local cfg_mail_smtp_user="${MAIL_SMTP_USER:-}"
@@ -239,8 +247,13 @@ load_config() {
local cfg_mail_smtp_ssl="${MAIL_SMTP_SSL:-0}"
if [ -f "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
source "$CONFIG_FILE"
local _strict_peek
_strict_peek="$(ssh_monitor_config_peek CONFIG_STRICT_PERMS "$CONFIG_FILE" "0")"
if ! ssh_monitor_check_config_file_perms "$CONFIG_FILE" "$_strict_peek"; then
exit 1
fi
# shellcheck disable=SC1090
source "$CONFIG_FILE"
else
printf 'WARN: Конфиг %s не найден, использую значения по умолчанию\n' "$CONFIG_FILE" >&2
fi
@@ -303,6 +316,8 @@ load_config() {
SAC_TIMEOUT_SEC="$(trim_value "${SAC_TIMEOUT_SEC:-$cfg_sac_timeout_sec}")"
SAC_SPOOL_FLUSH_MAX_FILES="$(trim_value "${SAC_SPOOL_FLUSH_MAX_FILES:-$cfg_sac_spool_flush_max}")"
SAC_SPOOL_MAX_AGE_HOURS="$(trim_value "${SAC_SPOOL_MAX_AGE_HOURS:-$cfg_sac_spool_max_age_h}")"
SAC_TLS_INSECURE="$(trim_value "${SAC_TLS_INSECURE:-$cfg_sac_tls_insecure}")"
CONFIG_STRICT_PERMS="$(trim_value "${CONFIG_STRICT_PERMS:-$cfg_config_strict_perms}")"
MAIL_SMTP_HOST="$(trim_value "${MAIL_SMTP_HOST:-$cfg_mail_smtp_host}")"
MAIL_SMTP_PORT="${MAIL_SMTP_PORT:-$cfg_mail_smtp_port}"
MAIL_SMTP_USER="$(trim_value "${MAIL_SMTP_USER:-$cfg_mail_smtp_user}")"
@@ -388,6 +403,35 @@ notify_chain_human() {
(IFS=','; printf '%s' "${NOTIFY_CHAIN[*]}")
}
write_state_file() {
local path="$1" content="$2"
ssh_monitor_secure_file "$path"
printf '%s' "$content" >"$path"
}
ssh_monitor_audit_state_paths() {
local path
for path in \
"${SAC_SPOOL_DIR:-}" \
"${SAC_FAIL_COUNT_FILE:-}" \
"${LAST_HEARTBEAT_FILE:-}" \
"${LAST_REPORT_FILE:-}" \
"${LAST_SSH_CHECK_FILE:-}" \
"${LAST_SUDO_CHECK_FILE:-}" \
"${LAST_SECURITY_EVENTS_FILE:-}" \
"${LAST_LOGIND_CHECK_FILE:-}" \
"${BAN_LIST_FILE:-}" \
"${HEALTHCHECK_STATUS_FILE:-}" \
"/etc/ssh_monitor_whitelist.txt"; do
[ -n "$path" ] || continue
if [ -d "$path" ]; then
ssh_monitor_warn_path_perms "$path" "каталог state" 700
elif [ -e "$path" ]; then
ssh_monitor_warn_path_perms "$path" "файл state" 600
fi
done
}
validate_config() {
validate_report_hour
validate_numeric_or_default "BAN_TIME" "3600"
@@ -434,6 +478,12 @@ validate_config() {
printf '%s\n' 'Не настроен ни один канал отправки оповещений' >&2
exit 1
fi
if [ -n "${BACKUP_WEBHOOK_URL:-}" ]; then
printf 'WARN: BACKUP_WEBHOOK_URL deprecated (legacy fallback); будет удалён в 2.3.x\n' >&2
fi
if command -v sac_warn_tls_insecure >/dev/null 2>&1; then
sac_warn_tls_insecure
fi
}
# ============================================
@@ -1045,6 +1095,7 @@ block_ip() {
return 1
fi
ssh_monitor_secure_file "$BAN_LIST_FILE" 2>/dev/null || true
echo "$ip|$ban_until|$attempts|$(date '+%Y-%m-%d %H:%M:%S')" >>"$BAN_LIST_FILE"
save_firewall_state
@@ -1214,6 +1265,7 @@ load_whitelist_from_file() {
local whitelist_file="/etc/ssh_monitor_whitelist.txt"
if [ -f "$whitelist_file" ]; then
ssh_monitor_warn_path_perms "$whitelist_file" "whitelist" 600
write_log "Загрузка белого списка из $whitelist_file"
while IFS= read -r line || [ -n "$line" ]; do
@@ -1369,7 +1421,7 @@ monitor_ssh() {
done <<<"$ssh_logs"
fi
echo "$current_time" >"$LAST_SSH_CHECK_FILE"
write_state_file "$LAST_SSH_CHECK_FILE" "$current_time"
}
# ============================================
@@ -1486,7 +1538,7 @@ monitor_sudo() {
done <<<"$sudo_logs"
fi
echo "$current_time" >"$LAST_SUDO_CHECK_FILE"
write_state_file "$LAST_SUDO_CHECK_FILE" "$current_time"
}
# ============================================
@@ -1551,7 +1603,7 @@ monitor_security_events() {
fi
fi
echo "$now" >"$LAST_SECURITY_EVENTS_FILE"
write_state_file "$LAST_SECURITY_EVENTS_FILE" "$now"
}
# ============================================
@@ -1709,7 +1761,7 @@ monitor_logind() {
done <<<"$logind_lines"
fi
echo "$current_time" >"$LAST_LOGIND_CHECK_FILE"
write_state_file "$LAST_LOGIND_CHECK_FILE" "$current_time"
}
# ============================================
@@ -1877,7 +1929,7 @@ PY
notify_or_sac "report.daily.ssh" "info" "Ежедневный отчёт SSH" "$sac_summary" "$message" "$sac_details_json"
write_log "Ежедневный отчет отправлен"
echo "$(report_date '+%F')" >"$LAST_REPORT_FILE"
write_state_file "$LAST_REPORT_FILE" "$(report_date '+%F')"
}
read_last_report_date() {
@@ -1936,7 +1988,7 @@ send_heartbeat() {
message="${message}🕐 Время: ${timestamp_human}"
notify_or_sac "agent.heartbeat" "info" "Heartbeat" "ssh-monitor alive" "$message"
echo "$timestamp" >"$LAST_HEARTBEAT_FILE"
write_state_file "$LAST_HEARTBEAT_FILE" "$timestamp"
write_log "Heartbeat отправлен"
}
@@ -1965,9 +2017,11 @@ write_health_and_metrics() {
local ts
ts=$(date '+%s')
if [ -n "${HEALTHCHECK_STATUS_FILE:-}" ]; then
mkdir -p "$(dirname "$HEALTHCHECK_STATUS_FILE")" 2>/dev/null || true
ssh_monitor_secure_dir "$(dirname "$HEALTHCHECK_STATUS_FILE")" 2>/dev/null || mkdir -p "$(dirname "$HEALTHCHECK_STATUS_FILE")" 2>/dev/null || true
printf '{"ts":%s,"hostname":"%s","dry_run":%s}\n' "$ts" "$(hostname | sed 's/"/\\"/g')" "$([[ "${DRY_RUN:-0}" = "1" ]] && echo true || echo false)" >"${HEALTHCHECK_STATUS_FILE}.tmp"
ssh_monitor_secure_file "${HEALTHCHECK_STATUS_FILE}.tmp" 2>/dev/null || true
mv "${HEALTHCHECK_STATUS_FILE}.tmp" "$HEALTHCHECK_STATUS_FILE" 2>/dev/null || true
ssh_monitor_secure_file "$HEALTHCHECK_STATUS_FILE" 2>/dev/null || true
fi
if [ -n "${PROMETHEUS_TEXTFILE_DIR:-}" ]; then
mkdir -p "$PROMETHEUS_TEXTFILE_DIR" 2>/dev/null || true
@@ -2097,6 +2151,11 @@ run_check_config() {
printf 'MAIL_SMTP_PASSWORD: %s\n' "$([ -n "${MAIL_SMTP_PASSWORD:-}" ] && echo задан || echo не задан)"
printf 'UseSAC=%s SAC_URL=%s SAC_API_KEY=%s\n' "${UseSAC:-off}" "${SAC_URL:-}" \
"$([ -n "${SAC_API_KEY:-}" ] && echo задан || echo не задан)"
printf 'CONFIG_STRICT_PERMS=%s SAC_TLS_INSECURE=%s\n' "${CONFIG_STRICT_PERMS:-0}" "${SAC_TLS_INSECURE:-0}"
if [ -n "${BACKUP_WEBHOOK_URL:-}" ]; then
printf 'WARN: BACKUP_WEBHOOK_URL deprecated (legacy fallback); будет удалён в 2.3.x\n'
fi
ssh_monitor_audit_state_paths
if bash -n "$0" 2>/dev/null; then
printf 'bash -n: OK\n'
else
@@ -2138,7 +2197,7 @@ fi
trap send_shutdown_notification EXIT INT TERM
mkdir -p "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
ssh_monitor_secure_file "$LOG_FILE" 2>/dev/null || touch "$LOG_FILE"
write_log "Конфигурация загружена из $CONFIG_FILE (или дефолты), версия ${SSH_MONITOR_VERSION:-?}, очередь оповещений: $(notify_chain_human)"
if [ "${ENABLE_IP_BAN:-1}" = "1" ]; then