#!/usr/bin/env bash ###################################################################################### #### port-exposure-scanner.sh — Audit listening ports against an allow-list #### #### Detects unauthorized listeners, missing expected services, and reports #### #### per-port detail including process names and PIDs. #### #### Requires: bash 4+, ss (iproute2) #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### sudo ./port-exposure-scanner.sh --role webserver #### #### sudo ./port-exposure-scanner.sh --allow-list /etc/port-exposure.conf #### #### #### #### See --help for all options. #### ###################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── ALLOW_LIST_FILE="/etc/port-exposure-scanner/allow-list.conf" ALLOW_LIST_PROVIDED=false ROLE="" INCLUDE_UDP=false TEXTFILE_MODE=false PROM_FILE="/var/lib/node_exporter/port_exposure.prom" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" # text, tap, junit JUNIT_FILE="${JUNIT_FILE:-port-exposure-results.xml}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── PASS=0 FAIL=0 WARN=0 TOTAL=0 RESULTS=() START_TIME="" CAN_SHOW_PROCESS=true # Allow-list entries: associative array port/proto -> description declare -A ALLOWED_PORTS # Discovered listeners: associative array port/proto -> process_info declare -A LISTENING_PORTS # Unauthorized port details for Prometheus labels declare -a UNAUTHORIZED_DETAILS=() # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; } # ── Test Result Recording ───────────────────────────────────────────── record_pass() { local name="$1" local detail="${2:-}" ((PASS++)) || true ((TOTAL++)) || true RESULTS+=("PASS|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name}" elif [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e " ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}" fi } record_fail() { local name="$1" local detail="${2:-}" ((FAIL++)) || true ((TOTAL++)) || true RESULTS+=("FAIL|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "not ok ${TOTAL} - ${name}" [[ -n "$detail" ]] && echo " # ${detail}" elif [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e " ${RED}✗${RESET} ${name}${detail:+ — ${detail}}" fi } record_warn() { local name="$1" local detail="${2:-}" ((WARN++)) || true ((TOTAL++)) || true RESULTS+=("WARN|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name} # SKIP ${detail}" elif [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e " ${YELLOW}⊘${RESET} ${name}${detail:+ — ${detail}}" fi } # ── JUnit XML Writer ────────────────────────────────────────────────── write_junit() { local end_time end_time=$(date +%s) local duration=$(( end_time - START_TIME )) cat > "$JUNIT_FILE" < JUNIT_EOF for result in "${RESULTS[@]}"; do local status name detail status=$(echo "$result" | cut -d'|' -f1) name=$(echo "$result" | cut -d'|' -f2) detail=$(echo "$result" | cut -d'|' -f3) # XML-escape name="${name//&/&}" name="${name///>}" name="${name//\"/"}" detail="${detail//&/&}" detail="${detail///>}" detail="${detail//\"/"}" case "$status" in PASS) echo " " >> "$JUNIT_FILE" [[ -n "$detail" ]] && echo " ${detail}" >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; FAIL) echo " " >> "$JUNIT_FILE" echo " FAILED: ${name} — ${detail}" >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; WARN) echo " " >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; esac done echo " " >> "$JUNIT_FILE" echo "" >> "$JUNIT_FILE" log "JUnit report written to ${JUNIT_FILE}" } # ══════════════════════════════════════════════════════════════════════ # ALLOW-LIST LOADING # ══════════════════════════════════════════════════════════════════════ # ── Built-in roles ──────────────────────────────────────────────────── load_role() { local role="$1" case "$role" in minimal) ALLOWED_PORTS["22/tcp"]="SSH" ;; webserver) ALLOWED_PORTS["22/tcp"]="SSH" ALLOWED_PORTS["80/tcp"]="HTTP" ALLOWED_PORTS["443/tcp"]="HTTPS" ;; database) ALLOWED_PORTS["22/tcp"]="SSH" ALLOWED_PORTS["3306/tcp"]="MySQL" ALLOWED_PORTS["5432/tcp"]="PostgreSQL" ;; monitoring) ALLOWED_PORTS["22/tcp"]="SSH" ALLOWED_PORTS["9090/tcp"]="Prometheus" ALLOWED_PORTS["9093/tcp"]="Alertmanager" ALLOWED_PORTS["9100/tcp"]="node_exporter" ALLOWED_PORTS["3000/tcp"]="Grafana" ;; *) err "Unknown role: ${role}" err "Available roles: minimal, webserver, database, monitoring" exit 1 ;; esac verbose "Loaded role '${role}' with ${#ALLOWED_PORTS[@]} allowed ports" } # ── Parse allow-list config file ────────────────────────────────────── load_allow_list_file() { local file="$1" if [[ ! -f "$file" ]]; then err "Allow-list file not found: ${file}" exit 1 fi if [[ ! -r "$file" ]]; then err "Allow-list file not readable: ${file}" exit 1 fi local line_num=0 while IFS= read -r line || [[ -n "$line" ]]; do ((line_num++)) || true # Strip leading/trailing whitespace line="${line#"${line%%[![:space:]]*}"}" line="${line%"${line##*[![:space:]]}"}" # Skip empty lines and comments [[ -z "$line" || "$line" =~ ^# ]] && continue # Extract description from inline comment local description="" if [[ "$line" =~ \#(.*) ]]; then description="${BASH_REMATCH[1]}" description="${description#"${description%%[![:space:]]*}"}" fi # Extract port/protocol token (first field) local token token=$(echo "$line" | awk '{print $1}') local port proto if [[ "$token" =~ ^([0-9]+)/(.+)$ ]]; then port="${BASH_REMATCH[1]}" proto="${BASH_REMATCH[2]}" elif [[ "$token" =~ ^[0-9]+$ ]]; then port="$token" proto="tcp" else warn "Ignoring invalid line ${line_num} in ${file}: ${line}" continue fi ALLOWED_PORTS["${port}/${proto}"]="${description:-port ${port}}" verbose "Allow-listed: ${port}/${proto} (${description:-port ${port}})" done < "$file" verbose "Loaded ${#ALLOWED_PORTS[@]} allowed ports from ${file}" } # ══════════════════════════════════════════════════════════════════════ # PORT SCANNING # ══════════════════════════════════════════════════════════════════════ # ── Parse ss output into LISTENING_PORTS ────────────────────────────── scan_ports() { local proto="$1" local ss_flag="$2" verbose "Scanning ${proto} listeners with ss ${ss_flag}" local ss_output if [[ "$CAN_SHOW_PROCESS" == "true" ]]; then ss_output=$(ss "${ss_flag}" -n -p 2>/dev/null) || ss_output=$(ss "${ss_flag}" -n 2>/dev/null) else ss_output=$(ss "${ss_flag}" -n 2>/dev/null) || true fi # Skip header line; parse each listening entry while IFS= read -r line; do [[ "$line" =~ ^State ]] && continue [[ -z "$line" ]] && continue # Extract local address:port (4th column) local local_addr local_addr=$(echo "$line" | awk '{print $4}') local port # Handle IPv6 [::]:port and IPv4 0.0.0.0:port and *:port if [[ "$local_addr" =~ ]:([0-9]+)$ ]]; then port="${BASH_REMATCH[1]}" elif [[ "$local_addr" =~ :([0-9]+)$ ]]; then port="${BASH_REMATCH[1]}" else continue fi # Extract process info if available local process_info="" if [[ "$line" =~ users:\(\(\"([^\"]+)\",pid=([0-9]+) ]]; then process_info="${BASH_REMATCH[1]}:${BASH_REMATCH[2]}" fi local key="${port}/${proto}" # Only record once per port/proto (first match wins) if [[ -z "${LISTENING_PORTS[$key]+x}" ]]; then LISTENING_PORTS["$key"]="${process_info:-unknown}" verbose "Found listener: ${key} (${process_info:-unknown})" fi done <<< "$ss_output" } # ══════════════════════════════════════════════════════════════════════ # ANALYSIS # ══════════════════════════════════════════════════════════════════════ analyze_ports() { local auth_listening=0 local auth_missing=0 local unauth_listening=0 # ── Check authorized ports ──────────────────────────────────────── if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo "" echo -e "${BOLD}Authorized Ports${RESET}" fi for key in $(echo "${!ALLOWED_PORTS[@]}" | tr ' ' '\n' | sort -t/ -k1 -n); do local desc="${ALLOWED_PORTS[$key]}" local port="${key%%/*}" local proto="${key##*/}" if [[ -n "${LISTENING_PORTS[$key]+x}" ]]; then local proc="${LISTENING_PORTS[$key]}" record_pass "Port ${port}/${proto} listening" "${desc} (${proc})" ((auth_listening++)) || true else record_warn "Port ${port}/${proto} not listening" "expected: ${desc}" ((auth_missing++)) || true fi done # ── Check unauthorized ports ────────────────────────────────────── local has_unauthorized=false for key in $(echo "${!LISTENING_PORTS[@]}" | tr ' ' '\n' | sort -t/ -k1 -n); do if [[ -z "${ALLOWED_PORTS[$key]+x}" ]]; then if [[ "$has_unauthorized" == "false" && "$OUTPUT_FORMAT" == "text" ]]; then echo "" echo -e "${BOLD}Unauthorized Ports${RESET}" has_unauthorized=true fi local port="${key%%/*}" local proto="${key##*/}" local proc="${LISTENING_PORTS[$key]}" local proc_name="" proc_pid="" if [[ "$proc" =~ ^(.+):([0-9]+)$ ]]; then proc_name="${BASH_REMATCH[1]}" proc_pid="${BASH_REMATCH[2]}" else proc_name="${proc}" proc_pid="n/a" fi record_fail "Port ${port}/${proto} unauthorized" "process=${proc_name} pid=${proc_pid}" UNAUTHORIZED_DETAILS+=("${port}|${proto}|${proc_name}") ((unauth_listening++)) || true fi done if [[ "$has_unauthorized" == "false" && "$OUTPUT_FORMAT" == "text" ]]; then echo "" echo -e "${BOLD}Unauthorized Ports${RESET}" echo -e " ${GREEN}✓${RESET} No unauthorized listeners detected" fi # Store counts for Prometheus / summary AUTH_LISTENING=$auth_listening AUTH_MISSING=$auth_missing UNAUTH_LISTENING=$unauth_listening } # ══════════════════════════════════════════════════════════════════════ # PROMETHEUS TEXTFILE # ══════════════════════════════════════════════════════════════════════ write_prometheus() { local prom_dir prom_dir=$(dirname "$PROM_FILE") if [[ ! -d "$prom_dir" ]]; then warn "Prometheus textfile directory does not exist: ${prom_dir}" warn "Skipping textfile write. Create the directory or use --prom-file." return fi local tmpfile="${PROM_FILE}.$$" { echo "# HELP port_exposure_authorized_listening Number of authorized ports that are listening" echo "# TYPE port_exposure_authorized_listening gauge" echo "port_exposure_authorized_listening ${AUTH_LISTENING}" echo "" echo "# HELP port_exposure_authorized_missing Number of authorized ports not listening" echo "# TYPE port_exposure_authorized_missing gauge" echo "port_exposure_authorized_missing ${AUTH_MISSING}" echo "" echo "# HELP port_exposure_unauthorized_listening Number of unauthorized ports listening" echo "# TYPE port_exposure_unauthorized_listening gauge" echo "port_exposure_unauthorized_listening ${UNAUTH_LISTENING}" echo "" echo "# HELP port_exposure_scan_timestamp Unix timestamp of last scan" echo "# TYPE port_exposure_scan_timestamp gauge" echo "port_exposure_scan_timestamp $(date +%s)" echo "" echo "# HELP port_exposure_unauthorized_port Per-port indicator for unauthorized listeners" echo "# TYPE port_exposure_unauthorized_port gauge" for entry in "${UNAUTHORIZED_DETAILS[@]}"; do local port proto process port=$(echo "$entry" | cut -d'|' -f1) proto=$(echo "$entry" | cut -d'|' -f2) process=$(echo "$entry" | cut -d'|' -f3) echo "port_exposure_unauthorized_port{port=\"${port}\",protocol=\"${proto}\",process=\"${process}\"} 1" done } > "$tmpfile" mv "$tmpfile" "$PROM_FILE" log "Prometheus metrics written to ${PROM_FILE}" } # ══════════════════════════════════════════════════════════════════════ # OUTPUT # ══════════════════════════════════════════════════════════════════════ print_summary() { local end_time end_time=$(date +%s) local duration=$(( end_time - START_TIME )) echo "" echo -e "${BOLD}────────────────────────────────────────${RESET}" echo -e "${BOLD}Summary${RESET}" echo -e " Authorized listening: ${AUTH_LISTENING}" echo -e " Authorized missing: ${AUTH_MISSING}" echo -e " Unauthorized: ${UNAUTH_LISTENING}" echo -e " ${GREEN}${PASS} passed${RESET} ${RED}${FAIL} failed${RESET} ${YELLOW}${WARN} warnings${RESET} (${duration}s)" echo -e "${BOLD}────────────────────────────────────────${RESET}" if [[ $FAIL -eq 0 ]]; then echo -e "${GREEN}${BOLD}All checks passed.${RESET}" else echo -e "${RED}${BOLD}${FAIL} unauthorized port(s) detected.${RESET}" fi } print_tap_header() { echo "TAP version 13" } print_tap_footer() { echo "1..${TOTAL}" echo "# pass ${PASS}" echo "# fail ${FAIL}" echo "# warn ${WARN}" } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ usage() { cat </dev/null; then err "ss command not found. Install iproute2." exit 1 fi START_TIME=$(date +%s) if [[ "$OUTPUT_FORMAT" == "tap" ]]; then print_tap_header elif [[ "$OUTPUT_FORMAT" == "text" ]]; then echo "" echo -e "${BOLD}Port Exposure Scanner${RESET}" echo -e "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)" if [[ -n "$ROLE" ]]; then echo -e "Role: ${ROLE}" else echo -e "Allow-list: ${ALLOW_LIST_FILE}" fi local proto_str="TCP" [[ "$INCLUDE_UDP" == "true" ]] && proto_str="TCP, UDP" echo -e "Protocols: ${proto_str}" fi # ── Scan listening ports ────────────────────────────────────────── scan_ports "tcp" "-tln" if [[ "$INCLUDE_UDP" == "true" ]]; then scan_ports "udp" "-uln" fi verbose "Found ${#LISTENING_PORTS[@]} unique listening ports" # ── Analyze ─────────────────────────────────────────────────────── analyze_ports # ── Output ──────────────────────────────────────────────────────── if [[ "$OUTPUT_FORMAT" == "tap" ]]; then print_tap_footer elif [[ "$OUTPUT_FORMAT" == "junit" ]]; then print_summary write_junit else print_summary fi # ── Prometheus textfile ─────────────────────────────────────────── if [[ "$TEXTFILE_MODE" == "true" ]]; then write_prometheus fi # Exit code [[ $FAIL -eq 0 ]] && exit 0 || exit 1 } main "$@"