a1a17e81a1
Includes updated JS challenge scripts with Claude-User whitelist, same-site referer bypass, Blackbox-Exporter allowed bot, and all new exporters, cheat sheets, and automation scripts.
360 lines
14 KiB
Bash
360 lines
14 KiB
Bash
#!/usr/bin/env bash
|
|
|
|
#########################################################################################
|
|
#### systemd-hardening-auditor.sh — Audit systemd services for security hardening ####
|
|
#### Checks PrivateTmp, NoNewPrivileges, ProtectSystem, and more ####
|
|
#### ####
|
|
#### Author: Phil Connor ####
|
|
#### Contact: contact@mylinux.work ####
|
|
#### License: MIT ####
|
|
#### Version 1.00 ####
|
|
#### ####
|
|
#### Usage: ####
|
|
#### ./systemd-hardening-auditor.sh ####
|
|
#### ./systemd-hardening-auditor.sh --service nginx ####
|
|
#### ./systemd-hardening-auditor.sh --json ####
|
|
#### ####
|
|
#### See --help for all options. ####
|
|
#########################################################################################
|
|
|
|
set -euo pipefail
|
|
|
|
# ── Defaults ──────────────────────────────────────────────────────────
|
|
OUTPUT_FORMAT="text"
|
|
TARGET_SERVICE=""
|
|
ONLY_FAILING=false
|
|
MIN_SCORE=""
|
|
TEXTFILE_PATH=""
|
|
INCLUDE_SYSTEMD=false
|
|
NO_COLOR=false
|
|
|
|
# ── Hardening directives ─────────────────────────────────────────────
|
|
DIRECTIVES=(
|
|
PrivateTmp PrivateDevices PrivateNetwork
|
|
ProtectSystem ProtectHome NoNewPrivileges
|
|
ProtectKernelTunables ProtectKernelModules ProtectControlGroups
|
|
RestrictSUIDSGID ProtectClock ProtectKernelLogs
|
|
ProtectHostname RestrictNamespaces RestrictRealtime
|
|
MemoryDenyWriteExecute LockPersonality
|
|
)
|
|
TOTAL_DIRECTIVES=${#DIRECTIVES[@]}
|
|
|
|
# ── Systemd internal prefixes excluded by default ────────────────────
|
|
SYSTEMD_INTERNAL_PREFIXES=("systemd-" "init.scope" "dbus")
|
|
|
|
# ── Result storage ───────────────────────────────────────────────────
|
|
declare -a R_SERVICES=() R_SCORES=() R_PCTS=() R_DISABLED=()
|
|
|
|
# ── Colors ────────────────────────────────────────────────────────────
|
|
RED="" GREEN="" YELLOW="" BOLD="" RESET=""
|
|
|
|
setup_colors() {
|
|
[[ "$NO_COLOR" == true ]] && return
|
|
if [[ -t 1 ]]; then
|
|
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
|
|
BOLD='\033[1m' RESET='\033[0m'
|
|
fi
|
|
}
|
|
|
|
# ── Usage ─────────────────────────────────────────────────────────────
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: $(basename "$0") [OPTIONS]
|
|
|
|
Audit running systemd services for security hardening directives.
|
|
|
|
Options:
|
|
--service NAME Audit a single service (e.g., nginx)
|
|
--json Output results as JSON array
|
|
--csv Output results as CSV
|
|
--only-failing Show only services scoring below 50%
|
|
--min-score N Show only services scoring below N%
|
|
--textfile PATH Write Prometheus metrics to file
|
|
--include-systemd Include systemd internal services
|
|
--no-color Disable colored output
|
|
--help Show this help
|
|
|
|
Checks ${TOTAL_DIRECTIVES} hardening directives:
|
|
$(printf '%s, ' "${DIRECTIVES[@]}" | sed 's/, $//')
|
|
|
|
Examples:
|
|
$(basename "$0")
|
|
$(basename "$0") --service nginx
|
|
$(basename "$0") --json
|
|
$(basename "$0") --only-failing
|
|
$(basename "$0") --textfile /var/lib/node_exporter/textfile_collector/systemd_hardening.prom
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
# ── Argument parsing ─────────────────────────────────────────────────
|
|
parse_args() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--service)
|
|
[[ -z "${2:-}" ]] && { echo "Error: --service requires a name" >&2; exit 1; }
|
|
TARGET_SERVICE="$2"; shift 2 ;;
|
|
--json) OUTPUT_FORMAT="json"; NO_COLOR=true; shift ;;
|
|
--csv) OUTPUT_FORMAT="csv"; NO_COLOR=true; shift ;;
|
|
--only-failing) ONLY_FAILING=true; shift ;;
|
|
--min-score)
|
|
[[ -z "${2:-}" ]] && { echo "Error: --min-score requires a number" >&2; exit 1; }
|
|
MIN_SCORE="$2"; shift 2 ;;
|
|
--textfile)
|
|
[[ -z "${2:-}" ]] && { echo "Error: --textfile requires a path" >&2; exit 1; }
|
|
TEXTFILE_PATH="$2"; shift 2 ;;
|
|
--include-systemd) INCLUDE_SYSTEMD=true; shift ;;
|
|
--no-color) NO_COLOR=true; shift ;;
|
|
--help|-h) usage ;;
|
|
*) echo "Error: unknown option: $1" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# ── Helper functions ─────────────────────────────────────────────────
|
|
|
|
is_systemd_internal() {
|
|
local svc="$1"
|
|
for prefix in "${SYSTEMD_INTERNAL_PREFIXES[@]}"; do
|
|
[[ "$svc" == ${prefix}* ]] && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
get_services() {
|
|
if [[ -n "$TARGET_SERVICE" ]]; then
|
|
local svc="$TARGET_SERVICE"
|
|
[[ "$svc" != *.service ]] && svc="${svc}.service"
|
|
if ! systemctl show "$svc" --property=LoadState 2>/dev/null | grep -q "LoadState=loaded"; then
|
|
echo "Error: service '$svc' not found or not loaded" >&2
|
|
exit 1
|
|
fi
|
|
echo "$svc"
|
|
return
|
|
fi
|
|
systemctl list-units --type=service --state=running --no-legend --no-pager 2>/dev/null \
|
|
| awk '{print $1}' | sort
|
|
}
|
|
|
|
# Check if a directive is enabled for a given service
|
|
check_directive() {
|
|
local svc="$1" directive="$2"
|
|
local value
|
|
value=$(systemctl show "$svc" --property="$directive" 2>/dev/null | cut -d= -f2-)
|
|
[[ -z "$value" ]] && return 1
|
|
|
|
case "$directive" in
|
|
ProtectSystem)
|
|
case "$value" in strict|full|yes|true) return 0 ;; *) return 1 ;; esac ;;
|
|
ProtectHome)
|
|
case "$value" in yes|read-only|tmpfs|true) return 0 ;; *) return 1 ;; esac ;;
|
|
RestrictNamespaces)
|
|
case "$value" in false|no|"") return 1 ;; *) return 0 ;; esac ;;
|
|
*)
|
|
case "$value" in yes|true) return 0 ;; *) return 1 ;; esac ;;
|
|
esac
|
|
}
|
|
|
|
progress_bar() {
|
|
local score="$1" total="$2" bar="" i
|
|
for ((i = 0; i < score; i++)); do bar+="█"; done
|
|
for ((i = score; i < total; i++)); do bar+="░"; done
|
|
echo "$bar"
|
|
}
|
|
|
|
score_color() {
|
|
local pct="$1"
|
|
if [[ "$pct" -ge 70 ]]; then echo -ne "$GREEN"
|
|
elif [[ "$pct" -ge 40 ]]; then echo -ne "$YELLOW"
|
|
else echo -ne "$RED"
|
|
fi
|
|
}
|
|
|
|
# ── Audit one service, store results directly (no subshell) ──────────
|
|
audit_and_collect() {
|
|
local svc="$1" score=0
|
|
local disabled_list=()
|
|
|
|
for directive in "${DIRECTIVES[@]}"; do
|
|
if check_directive "$svc" "$directive"; then
|
|
((score++)) || true
|
|
else
|
|
disabled_list+=("$directive")
|
|
fi
|
|
done
|
|
|
|
local pct=$(( (score * 100) / TOTAL_DIRECTIVES ))
|
|
|
|
# Apply filters — return silently if filtered out
|
|
if [[ "$ONLY_FAILING" == true ]] && [[ "$pct" -ge 50 ]]; then return; fi
|
|
if [[ -n "$MIN_SCORE" ]] && [[ "$pct" -ge "$MIN_SCORE" ]]; then return; fi
|
|
|
|
R_SERVICES+=("$svc")
|
|
R_SCORES+=("$score")
|
|
R_PCTS+=("$pct")
|
|
local disabled_str=""
|
|
disabled_str=$(IFS='|'; echo "${disabled_list[*]+"${disabled_list[*]}"}")
|
|
R_DISABLED+=("$disabled_str")
|
|
}
|
|
|
|
# ── Output: text ─────────────────────────────────────────────────────
|
|
output_text() {
|
|
local hostname timestamp
|
|
hostname=$(hostname 2>/dev/null || echo "unknown")
|
|
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
echo -e "${BOLD}Systemd Service Hardening Audit${RESET}"
|
|
echo "Host: ${hostname}"
|
|
echo "Time: ${timestamp}"
|
|
echo ""
|
|
printf "%-35s %-8s %-7s %s\n" "SERVICE" "SCORE" "GRADE" "DIRECTIVES"
|
|
echo "──────────────────────────────────────────────────────────────────────────"
|
|
|
|
local count="${#R_SERVICES[@]}"
|
|
for ((i = 0; i < count; i++)); do
|
|
local svc="${R_SERVICES[$i]}" score="${R_SCORES[$i]}" pct="${R_PCTS[$i]}"
|
|
local bar color
|
|
bar=$(progress_bar "$score" "$TOTAL_DIRECTIVES")
|
|
color=$(score_color "$pct")
|
|
printf "${color}%-35s %2d/%-4d %3d%% %s${RESET}\n" "$svc" "$score" "$TOTAL_DIRECTIVES" "$pct" "$bar"
|
|
|
|
# List missing directives for poorly hardened services
|
|
if [[ "$pct" -lt 40 ]] && [[ -n "${R_DISABLED[$i]}" ]]; then
|
|
local IFS='|' items line="" c=0
|
|
read -ra items <<< "${R_DISABLED[$i]}"
|
|
for d in "${items[@]}"; do
|
|
if [[ $c -gt 0 ]] && [[ $((c % 3)) -eq 0 ]]; then
|
|
echo -e " ${RED}${line}${RESET}"; line=""
|
|
fi
|
|
line+=$(printf " ✗ %-28s" "$d")
|
|
((c++)) || true
|
|
done
|
|
[[ -n "$line" ]] && echo -e " ${RED}${line}${RESET}"
|
|
fi
|
|
done
|
|
|
|
# Summary
|
|
local total="${#R_SERVICES[@]}" sum_pct=0 below_50=0
|
|
for ((i = 0; i < total; i++)); do
|
|
sum_pct=$((sum_pct + R_PCTS[i]))
|
|
[[ "${R_PCTS[i]}" -lt 50 ]] && ((below_50++)) || true
|
|
done
|
|
local avg_pct=0
|
|
[[ "$total" -gt 0 ]] && avg_pct=$((sum_pct / total))
|
|
|
|
echo ""
|
|
echo "────────────────────────────────────────"
|
|
echo -e "${BOLD}Summary${RESET}"
|
|
printf " Total services: %d\n" "$total"
|
|
printf " Average score: %d%%\n" "$avg_pct"
|
|
printf " Below 50%%: %d service(s)\n" "$below_50"
|
|
echo "────────────────────────────────────────"
|
|
}
|
|
|
|
# ── Output: JSON ─────────────────────────────────────────────────────
|
|
output_json() {
|
|
local total="${#R_SERVICES[@]}"
|
|
echo "["
|
|
for ((i = 0; i < total; i++)); do
|
|
local svc="${R_SERVICES[$i]}" score="${R_SCORES[$i]}" pct="${R_PCTS[$i]}"
|
|
local disabled="${R_DISABLED[$i]}"
|
|
|
|
# Build missing array
|
|
local missing_json="[]"
|
|
if [[ -n "$disabled" ]]; then
|
|
missing_json="["
|
|
local IFS='|' first=true
|
|
read -ra items <<< "$disabled"
|
|
for d in "${items[@]}"; do
|
|
[[ "$first" == true ]] && first=false || missing_json+=","
|
|
missing_json+="\"${d}\""
|
|
done
|
|
missing_json+="]"
|
|
fi
|
|
|
|
local comma=","
|
|
[[ $((i + 1)) -eq "$total" ]] && comma=""
|
|
printf ' {"service":"%s","score":%d,"total":%d,"percentage":%d,"missing":%s}%s\n' \
|
|
"$svc" "$score" "$TOTAL_DIRECTIVES" "$pct" "$missing_json" "$comma"
|
|
done
|
|
echo "]"
|
|
}
|
|
|
|
# ── Output: CSV ──────────────────────────────────────────────────────
|
|
output_csv() {
|
|
echo "service,score,total,percentage,missing"
|
|
local total="${#R_SERVICES[@]}"
|
|
for ((i = 0; i < total; i++)); do
|
|
local missing="${R_DISABLED[$i]//|/;}"
|
|
printf '%s,%d,%d,%d,"%s"\n' \
|
|
"${R_SERVICES[$i]}" "${R_SCORES[$i]}" "$TOTAL_DIRECTIVES" "${R_PCTS[$i]}" "$missing"
|
|
done
|
|
}
|
|
|
|
# ── Output: Prometheus textfile ──────────────────────────────────────
|
|
output_prometheus() {
|
|
local path="$1" total="${#R_SERVICES[@]}"
|
|
local tmpfile
|
|
tmpfile=$(mktemp "${path}.XXXXXX" 2>/dev/null) || {
|
|
echo "Error: cannot write to ${path}" >&2; exit 1
|
|
}
|
|
|
|
{
|
|
echo "# HELP systemd_hardening_score Hardening score for a systemd service (0-${TOTAL_DIRECTIVES})"
|
|
echo "# TYPE systemd_hardening_score gauge"
|
|
for ((i = 0; i < total; i++)); do
|
|
echo "systemd_hardening_score{service=\"${R_SERVICES[$i]}\"} ${R_SCORES[$i]}"
|
|
done
|
|
echo "# HELP systemd_hardening_percentage Hardening percentage for a systemd service (0-100)"
|
|
echo "# TYPE systemd_hardening_percentage gauge"
|
|
for ((i = 0; i < total; i++)); do
|
|
echo "systemd_hardening_percentage{service=\"${R_SERVICES[$i]}\"} ${R_PCTS[$i]}"
|
|
done
|
|
echo "# HELP systemd_hardening_total Total hardening directives checked"
|
|
echo "# TYPE systemd_hardening_total gauge"
|
|
echo "systemd_hardening_total ${TOTAL_DIRECTIVES}"
|
|
} > "$tmpfile"
|
|
|
|
mv "$tmpfile" "$path"
|
|
echo "Wrote Prometheus metrics to ${path}" >&2
|
|
}
|
|
|
|
# ── Main ─────────────────────────────────────────────────────────────
|
|
main() {
|
|
parse_args "$@"
|
|
setup_colors
|
|
|
|
if ! command -v systemctl &>/dev/null; then
|
|
echo "Error: systemctl not found — this script requires systemd" >&2
|
|
exit 1
|
|
fi
|
|
|
|
local services
|
|
services=$(get_services)
|
|
if [[ -z "$services" ]]; then
|
|
echo "No running services found to audit" >&2
|
|
exit 0
|
|
fi
|
|
|
|
# Audit each service
|
|
while IFS= read -r svc; do
|
|
[[ -z "$svc" ]] && continue
|
|
if [[ "$INCLUDE_SYSTEMD" == false ]] && is_systemd_internal "$svc"; then
|
|
continue
|
|
fi
|
|
audit_and_collect "$svc"
|
|
done <<< "$services"
|
|
|
|
# Produce output
|
|
case "$OUTPUT_FORMAT" in
|
|
json) output_json ;;
|
|
csv) output_csv ;;
|
|
text) output_text ;;
|
|
esac
|
|
|
|
# Write Prometheus metrics if requested
|
|
[[ -n "$TEXTFILE_PATH" ]] && output_prometheus "$TEXTFILE_PATH"
|
|
}
|
|
|
|
main "$@"
|