#!/bin/sh # Copyright (c) 2023-2024 Eric Fahlgren # SPDX-License-Identifier: GPL-2.0 # shellcheck disable=SC2039,SC2155 # "local" not defined in POSIX sh PROG="$(command -v snort)" MAIN="/usr/share/snort/main.uc" CONF_DIR=$(uci -q get snort.snort.temp_dir || echo "/var/snort.d") CONF="${CONF_DIR}/snort_conf.lua" ACTION="usage" # Show help by default. VERBOSE=false QUIET=false TESTING= TABLE= NLINES=0 DATE_SPEC= PATTERN= [ ! -e "$CONF_DIR" ] && mkdir -p "$CONF_DIR" [ -e /dev/stdin ] && STDIN=/dev/stdin || STDIN=/proc/self/fd/0 [ -e /dev/stdout ] && STDOUT=/dev/stdout || STDOUT=/proc/self/fd/1 [ -t 2 ] && export TTY=1 die() { $QUIET || echo "$@" >&2 exit 1 } disable_offload() { # From https://forum.openwrt.org/t/snort-3-nfq-with-ips-mode/161172 # https://blog.snort.org/2016/08/running-snort-on-commodity-hardware.html # Not needed when running the nfq daq as defragmentation is done by the kernel. # What about pcap? local filter_method=$(uci -q get snort.snort.method) if [ "$filter_method" = "afpacket" ]; then local wan=$(uci get snort.snort.interface) if [ -n "$wan" ] && ethtool -k "$wan" | grep -q -E '(tcp-segmentation-offload|receive-offload): on' ; then ethtool -K "$wan" gro off lro off tso off 2> /dev/null log "Disabled gro, lro and tso on '$wan' using ethtool." fi fi } nft_rm_table() { for table_type in 'inet' 'netdev'; do nft list tables | grep -q "${table_type} snort" && nft delete table "${table_type}" snort done } nft_add_table() { if [ "$(uci -q get snort.snort.method)" = "nfq" ]; then local options $VERBOSE && options='-e' print nftables | nft $options -f $STDIN $VERBOSE && nft list table inet snort fi } setup() { # Generates all the configuration, then reports the config file for snort. # Does NOT generate the rules file, you'll need to do 'update-rules' first. local log_dir=$(uci get snort.snort.log_dir) [ ! -e "$log_dir" ] && mkdir -p "$log_dir" nft_rm_table print snort > "$CONF" nft_add_table echo "$CONF" } teardown() { # Merely cleans up after. nft_rm_table [ -e "$CONF" ] && rm "$CONF" } resetup() { QUIET=true check || die "The generated snort lua configuration contains errors, not restarting. Run 'snort-mgr check'" teardown setup } update_rules() { /usr/bin/snort-rules $TESTING } print() { # '$1' is optional file type to generate, one of: # config, snort, nftables or help local table="${1:-$TABLE}" utpl -D TYPE="$table" -D QUIET=$QUIET -S "$MAIN" } check() { local manual=$(uci get snort.snort.manual) [ "$manual" = 1 ] && return 0 $QUIET && OUT=/dev/null || OUT=$STDOUT local warn no_rules if $VERBOSE; then warn='--warn-all' no_rules=0 else warn='-q' no_rules=1 fi local test_conf="${CONF_DIR}/test_conf.lua" _SNORT_WITHOUT_RULES="$no_rules" print snort > "${test_conf}" || die "Errors during generation of snort config" if $PROG -T $warn -c "${test_conf}" 2> $OUT ; then rm "${test_conf}" else die "Errors in snort config tests. Examine ${test_conf} for issues" fi if [ "$(uci -q get snort.snort.method)" = "nfq" ]; then local options local test_nft="${CONF_DIR}/test_conf.nft" print nftables > "${test_nft}" || die "Errors during generation of nftables config" $VERBOSE && options='-e' if nft $options --check -f "${test_nft}" ; then rm "${test_nft}" else die "Errors in nftables config tests. Examine ${test_nft} for issues" fi fi } _filter_by_date() { # Grab all the alert_json files in the log directory, scan them # for matching timestamps and return those lines that match. local log_dir="$1" local operator date case "$DATE_SPEC" in ('') operator='>' ; date='' ;; (-*) operator='<' ; date="${DATE_SPEC:1}" ;; (+*) operator='>' ; date="${DATE_SPEC:1}" ;; (today) operator='>' ; date=$(date +'%y/%m/%d-') ;; (*) die "Invalid date specification '${DATE_SPEC}', did you forget the +/- prefix?" ;; esac # We need to create a single json array because 'jsonfilter -a' is # severely broken. awk ' BEGIN { print "[" } { print $0"," } END { print "{}]" } ' "${log_dir}"/*alert_json.txt \ | jsonfilter -e '$[@.timestamp '${operator}' "'"${date}"'"]' } report() { # Reported IPs have random source port stripped, but destination port # (if any) retained. local SORT="$(command -v sort)" if [ ! -x "${SORT}" ] || ! "${SORT}" --version 2> /dev/null | grep -q "coreutils"; then die "'snort-mgr report' requires coreutils-sort package" fi local logging=$(uci get snort.snort.logging) local log_dir=$(uci get snort.snort.log_dir) if [ "$logging" = 0 ]; then die "Logging is not enabled in snort config" fi #-- Collect the inputs -- local msg src srcP dst dstP dir gid sid local tmp=$(mktemp -t snort.rep.XXXXXX) _filter_by_date "${log_dir}" | while read -r line; do unset -v src dst srcP dstP eval "$(jsonfilter -s "$line" \ -e 'msg=$.msg' \ -e 'src=$.src_addr' \ -e 'dst=$.dst_addr' \ -e 'srcP=$.src_port' \ -e 'dstP=$.dst_port' \ -e 'dir=$.dir' \ -e 'gid=$.gid' \ -e 'sid=$.sid')" # Append the port to the IP, but only if it's meaningful. [ "$dir" = 'C2S' ] && [ -n "$dstP" ] && dst="${dst}(${dstP})" [ "$dir" = 'S2C' ] && [ -n "$srcP" ] && src="${src}(${srcP})" echo "$msg#$src#$dst#$dir#$gid#$sid" done | grep -iE "$PATTERN" > "$tmp" #-- Generate output -- local output [ "$NLINES" = 0 ] && output="cat" || output="head -n $NLINES" local lines=$($SORT "$tmp" | uniq -c | $SORT -nr | $output) rm "$tmp" if [ -z "$lines" ]; then echo -n "There were no incidents " [ -z "$PATTERN" ] && echo "reported." || echo "matching pattern '$PATTERN'." return fi local n_total=$(cat "${log_dir}"/*alert_json.txt | wc -l) local n_incidents=$(echo "$lines" | awk '{total += $1} END {print total}') local mlen=$(echo "$lines" | awk -F'#' '{print $1}' | wc -L) local slen=$(echo "$lines" | awk -F'#' '{print $2}' | wc -L) echo "Events involving ${PATTERN:-all IPs} - $(date -Is)" printf "%-*s %3s %5s %-3s %-*s %s\n" "$mlen" " Count Message" "gid" "sid" "Dir" "$slen" "Source" "Destination" echo "$lines" | awk -F'#' '{printf "%-'"$mlen"'s %3d %5d %s %-'"$slen"'s %s\n", $1, $5, $6, $4, $2, $3}' printf "%7d incidents shown of %d logged\n" "$n_incidents" "$n_total" #-- Lookup rules and references, if requested. -- if $VERBOSE; then local rules_dir="$(uci get snort.snort.config_dir)/rules" local usids="$(echo "$lines" | awk -F'#' '{print $5 "#" $6}' | $SORT -u | $SORT -t'#' -k1n -k2n)" local nsids="$(echo "$usids" | wc -w)" echo '' echo "$nsids unique rules triggered:" local rule local i=1 for sid in $usids; do eval "$(echo "$sid" | awk -F'#' '{printf "export gid=%s;export sid=%s", $1, $2}')" printf "%3d - gid=%3d sid=%5d " "$i" "$gid" "$sid" rule=$(grep -Hn "\bsid:${sid};" "$rules_dir"/*.rules) if [ "$gid" -ne 1 ] && echo "$rule" | grep -qv "\bgid:${gid};"; then # Many rules have gid implicitly '1', zero any that are not # explicit when expecting non-'1'. rule="" fi if [ -n "$rule" ]; then echo "$rule" | cut -c -120 else rule=$($PROG --list-builtin | grep "^${gid}:${sid}\b") if [ -n "$rule" ]; then echo "BUILTIN: ${rule}" fi fi i=$((i + 1)) done echo "" echo "Per-rule details may be viewed by specifying the appropriate gid and sid, e.g.:" echo " https://www.snort.org/rule-docs/$gid-$sid" # Look up the names of the IPs shown in report. # Note, on my dev box, nslookup fires rule 1:14777, so you get lots # of incidents if not suppressed. echo '' echo 'Hosts by name:' local IP local peerdns=$(ifstatus wan | jsonfilter -e '$["dns-server"][0]') echo "$lines" | awk -F'#' '{printf "%s\n%s\n", $2, $3}' | sed 's/(.*//' | sort -u \ | while read -r IP; do [ -z "$IP" ] && continue n=$(nslookup "$IP" | awk '/name = / {n=$NF} END{print n}') [ -z "$n" ] && [ -n "$peerdns" ] && n=$(nslookup "$IP" "$peerdns" | awk '/name = / {n=$NF} END{print n}') [ -z "$n" ] && n='--unknown host--' printf " %-39s %s\n" "$IP" "$n" done | $SORT -b -k2 fi } status() { echo -n 'snort is ' ; service snort status local mem_total mem_free eval "$(ubus call system info | jsonfilter -e 'mem_total=$.memory.total' -e 'mem_free=$.memory.free')" awk -v mem_total="$mem_total" -v mem_free="$mem_free" 'BEGIN { mem_used = mem_total - mem_free; printf "Total system memory=%.3fM Used=%.3fM (%.1f%%) Free=%.3fM (%.1f%%)\n", mem_total/1024**2, mem_used/1024**2, 100*mem_used/mem_total, mem_free/1024**2, 100*mem_free/mem_total; }' busybox ps w | grep -E "PID|$PROG " | grep -v grep if [ "$(uci -q get snort.snort.method)" = "nfq" ]; then nft list table inet snort fi } #------------------------------------------------------------------------------- usage() { local msg="$1" [ -n "$msg" ] && printf "ERROR: %s\n\n" "$msg" cat < snort-mgr --date-spec +23/12/20-09 report will process all incidents from from 2023-12-20 at 0900 and later. $0 update-rules [-t/--testing] Download and install the snort ruleset. -t = Generate a test-only ruleset, don't download anything. Testing mode generates a canned rule that matches IPv4 ping requests. A typical test scenario might look like: > snort-mgr -t update-rules > /etc/init.d/snort start > ping -c4 8.8.8.8 > snort-mgr report $0 print config|snort|nftables|help Print the rendered file contents. Table types are: config - Display contents of /etc/config/snort, but with all values and descriptions. Missing entries rendered with defaults. snort - The top-level snort configuration lua script, with includes. nftables - The nftables script used to define the input queues when using the 'nfq' DAQ, with any included content. help - Display config file help. $0 check [-q/--quiet] Test the rendered config using snort's check mode without applying it to the running system. $0 status Print the service status, system memory use and if nfq is the current daq, then the nftables with counter values and so on. USAGE exit 1 } while [ -n "$1" ]; do case "$1" in -h|--help) usage ;; -q|--quiet) QUIET=true ;; -v|--verbose) VERBOSE=true ;; -t|--testing) TESTING=-t ;; -n|--n-lines) [ -z "$2" ] && usage "'--n-lines' requires a value" NLINES="$2" shift ;; -d|--date-spec) [ -z "$2" ] && usage "'--date-spec' requires a value" DATE_SPEC="$2" shift ;; -p|--pattern) [ -z "$2" ] && usage "'--pattern' requires a value" PATTERN="$2" shift ;; print) [ -z "$2" ] && usage "'print' requires a table type" ACTION="$1" TABLE="$2" shift ;; setup|teardown|resetup|update-rules|check|report|status) ACTION="$1" ;; *) usage "'$1' is not a valid command or option" ;; esac shift done [ -n "$ACTION" ] && eval "$ACTION"