taler-monitoring v1.13.12
Multi-phase global SUMMARY, warn-filter dimmed context, SUMMARY chrome as meta; FP stage host-agent via francpaysan-stage-user (stagepaysan); monpages GOA + FP stage.
This commit is contained in:
commit
5a5431bfa9
88 changed files with 21996 additions and 0 deletions
802
check_surface.sh
Executable file
802
check_surface.sh
Executable file
|
|
@ -0,0 +1,802 @@
|
|||
#!/usr/bin/env bash
|
||||
# check_surface.sh — REMOTE-ONLY public surface / ecosystem scan.
|
||||
#
|
||||
# NOT in default phases. Explicit:
|
||||
# ./taler-monitoring.sh surface
|
||||
# ./taler-monitoring.sh -d hacktivism.ch surface
|
||||
#
|
||||
# Without -d / with generic ecosystem: catalog of taler.net, gnunet.org,
|
||||
# taler-systems.com, taler-ops, mattermost, …
|
||||
# With -d DOMAIN: expand domains.conf stack hosts + catalog entries matching
|
||||
# that domain + common subdomain guesses; port/protocol probes only remote.
|
||||
#
|
||||
# Rules:
|
||||
# - no SSH into targets, no local podman on targets
|
||||
# - ICMP/port alone never proves "down" — confirm via expected protocol
|
||||
# - catalogued host unreachable via protocol → ERROR
|
||||
# - version unknown after probes → WARN
|
||||
# - CVE hit (OSV) for identified software+version → ERROR
|
||||
#
|
||||
set -euo pipefail
|
||||
ROOT=$(cd "$(dirname "$0")" && pwd)
|
||||
# shellcheck source=lib.sh
|
||||
source "$ROOT/lib.sh"
|
||||
|
||||
set_area surface
|
||||
section "surface · remote ecosystem / domain inventory (outside-in only)"
|
||||
|
||||
CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}"
|
||||
# Common ports when scanning a host discovered without explicit list
|
||||
DEFAULT_SCAN_PORTS="${SURFACE_SCAN_PORTS:-22,80,443,993,8443,9418}"
|
||||
PORT_TIMEOUT="${SURFACE_PORT_TIMEOUT:-2}"
|
||||
HTTP_TIMEOUT="${SURFACE_HTTP_TIMEOUT:-12}"
|
||||
# CVE via OSV (public, no key). Disable: SURFACE_CVE=0
|
||||
: "${SURFACE_CVE:=1}"
|
||||
# nmap OS / service fingerprint (v1.8.0+). Disable: SURFACE_NMAP=0
|
||||
: "${SURFACE_NMAP:=1}"
|
||||
# OS detection (-O) often needs root; falls back to -sV when denied
|
||||
: "${SURFACE_NMAP_OS:=1}"
|
||||
# Extra ports when -d domain mode
|
||||
DOMAIN_EXTRA_PORTS="${SURFACE_DOMAIN_PORTS:-22,80,443,8443}"
|
||||
|
||||
declare -A HOST_PORTS # host -> comma ports
|
||||
declare -A HOST_PROTO # host -> expect proto
|
||||
declare -A HOST_LABEL # host -> short label
|
||||
declare -A HOST_EXPECT # host -> 1 if catalogued (must respond)
|
||||
ORDERED_HOSTS=()
|
||||
|
||||
add_host() {
|
||||
local h="$1" ports="${2:-}" proto="${3:-https}" label="${4:-}" expect="${5:-1}"
|
||||
h=$(printf '%s' "$h" | tr 'A-Z' 'a-z' | sed 's#^https\?://##;s#/.*##;s/\.$//')
|
||||
[ -n "$h" ] || return 0
|
||||
if [ -z "${HOST_PORTS[$h]+x}" ]; then
|
||||
ORDERED_HOSTS+=("$h")
|
||||
HOST_PORTS[$h]="${ports:-443}"
|
||||
HOST_PROTO[$h]="${proto:-https}"
|
||||
HOST_LABEL[$h]="${label:-$h}"
|
||||
HOST_EXPECT[$h]="$expect"
|
||||
else
|
||||
# merge ports
|
||||
local p
|
||||
for p in ${ports//,/ }; do
|
||||
case ",${HOST_PORTS[$h]}," in
|
||||
*",$p,"*) ;;
|
||||
*) HOST_PORTS[$h]="${HOST_PORTS[$h]},$p" ;;
|
||||
esac
|
||||
done
|
||||
[ "${HOST_EXPECT[$h]}" = "1" ] || HOST_EXPECT[$h]="$expect"
|
||||
fi
|
||||
}
|
||||
|
||||
load_catalog() {
|
||||
local line h ports proto label
|
||||
[ -f "$CATALOG" ] || {
|
||||
warn "catalog" "missing $CATALOG — using empty base list"
|
||||
return 0
|
||||
}
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
line=${line%%#*}
|
||||
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
[ -z "$line" ] && continue
|
||||
# host ports proto label
|
||||
h=$(echo "$line" | awk '{print $1}')
|
||||
ports=$(echo "$line" | awk '{print $2}')
|
||||
proto=$(echo "$line" | awk '{print $3}')
|
||||
label=$(echo "$line" | awk '{print $4}')
|
||||
[ "$ports" = "-" ] && ports=""
|
||||
add_host "$h" "$ports" "${proto:-https}" "${label:-}" 1
|
||||
done <"$CATALOG"
|
||||
info "catalog" "loaded $CATALOG · ${#ORDERED_HOSTS[@]} hosts"
|
||||
}
|
||||
|
||||
# Clear inventory (used when switching to domain-only scope)
|
||||
clear_hosts() {
|
||||
ORDERED_HOSTS=()
|
||||
unset HOST_PORTS HOST_PROTO HOST_LABEL HOST_EXPECT
|
||||
declare -gA HOST_PORTS HOST_PROTO HOST_LABEL HOST_EXPECT
|
||||
}
|
||||
|
||||
# Expand for -d domain: ONLY that domain (remote). No whole-ecosystem catalog.
|
||||
expand_domain_scope() {
|
||||
local d="$1" h
|
||||
d=${d#https://}; d=${d%/}
|
||||
d=${d#http://}
|
||||
info "scope" "domain mode −d $d (remote only · not full ecosystem catalog)"
|
||||
|
||||
clear_hosts
|
||||
|
||||
# stack endpoints from applied profile (always expected)
|
||||
for h in \
|
||||
"${BANK_PUBLIC:-}" \
|
||||
"${EXCHANGE_PUBLIC:-}" \
|
||||
"${MERCHANT_PUBLIC:-}" \
|
||||
"${PAIVANA_PUBLIC:-}"
|
||||
do
|
||||
[ -n "$h" ] || continue
|
||||
add_host "$h" "80,443,$DOMAIN_EXTRA_PORTS" https "stack" 1
|
||||
done
|
||||
|
||||
# apex + common subdomains (expected if DNS exists — set expect after DNS in scan,
|
||||
# but catalogued stack already expected; guesses start optional)
|
||||
for sub in "" www bank exchange taler merchant backend shop shops stage \
|
||||
git docs www2 api static media landing mon401
|
||||
do
|
||||
if [ -z "$sub" ]; then
|
||||
h="$d"
|
||||
else
|
||||
h="${sub}.${d}"
|
||||
fi
|
||||
# stack hosts already added as expect=1; guesses optional until we promote
|
||||
if [ -z "${HOST_PORTS[$h]+x}" ]; then
|
||||
add_host "$h" "$DOMAIN_EXTRA_PORTS" https "guess" 0
|
||||
fi
|
||||
done
|
||||
|
||||
# also pick catalog lines that belong to this domain only
|
||||
if [ -f "$CATALOG" ]; then
|
||||
local line ports proto label
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
line=${line%%#*}
|
||||
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
[ -z "$line" ] && continue
|
||||
h=$(echo "$line" | awk '{print $1}')
|
||||
h=$(printf '%s' "$h" | tr 'A-Z' 'a-z' | sed 's#^https\?://##;s#/.*##')
|
||||
if [ "$h" = "$d" ] || [[ "$h" == *".$d" ]]; then
|
||||
ports=$(echo "$line" | awk '{print $2}')
|
||||
proto=$(echo "$line" | awk '{print $3}')
|
||||
label=$(echo "$line" | awk '{print $4}')
|
||||
[ "$ports" = "-" ] && ports=""
|
||||
add_host "$h" "$ports" "${proto:-https}" "${label:-dom}" 1
|
||||
fi
|
||||
done <"$CATALOG"
|
||||
fi
|
||||
}
|
||||
|
||||
# Ecosystem mode: full catalog; all entries expected
|
||||
expand_ecosystem_scope() {
|
||||
info "scope" "ecosystem mode (taler.net / gnunet.org / taler-systems.com / taler-ops / mattermost, … · remote only)"
|
||||
local h
|
||||
for h in "${ORDERED_HOSTS[@]}"; do
|
||||
HOST_EXPECT[$h]=1
|
||||
done
|
||||
}
|
||||
|
||||
# --- remote probes (no SSH) ---
|
||||
dns_ok() {
|
||||
local h="$1"
|
||||
getent ahosts "$h" >/dev/null 2>&1 || getent hosts "$h" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# TCP connect only (not proof of service)
|
||||
tcp_open() {
|
||||
local h="$1" port="$2"
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$PORT_TIMEOUT" bash -c "echo >/dev/tcp/${h}/${port}" 2>/dev/null
|
||||
else
|
||||
bash -c "echo >/dev/tcp/${h}/${port}" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# ICMP optional — never sole grounds for ERROR
|
||||
ping_host() {
|
||||
local h="$1"
|
||||
ping -c 1 -W 2 "$h" >/dev/null 2>&1 || ping -c 1 -w 2 "$h" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# HTTPS probe: code, server header, version hints, cert subject/dates
|
||||
probe_https() {
|
||||
local h="$1" port="${2:-443}"
|
||||
local url hdr body code server via cert_end subj san
|
||||
url="https://${h}/"
|
||||
[ "$port" != "443" ] && url="https://${h}:${port}/"
|
||||
body=$(mktemp)
|
||||
hdr=$(mktemp)
|
||||
code=$(curl -skS -m "$HTTP_TIMEOUT" -D "$hdr" -o "$body" -w '%{http_code}' \
|
||||
--connect-timeout "$PORT_TIMEOUT" "$url" 2>/dev/null || echo 000)
|
||||
server=$(awk 'BEGIN{IGNORECASE=1} /^server:/{sub(/\r$/,""); sub(/^server:[[:space:]]*/,""); print; exit}' "$hdr" 2>/dev/null || true)
|
||||
via=$(awk 'BEGIN{IGNORECASE=1} /^x-powered-by:/{sub(/\r$/,""); sub(/^[^:]+:[[:space:]]*/,""); print; exit}' "$hdr" 2>/dev/null || true)
|
||||
# cert
|
||||
cert_end=$(echo | openssl s_client -servername "$h" -connect "${h}:${port}" 2>/dev/null \
|
||||
| openssl x509 -noout -enddate 2>/dev/null | sed 's/notAfter=//')
|
||||
subj=$(echo | openssl s_client -servername "$h" -connect "${h}:${port}" 2>/dev/null \
|
||||
| openssl x509 -noout -subject 2>/dev/null | head -1)
|
||||
# taler version hints in body
|
||||
local taler_hint
|
||||
taler_hint=$(grep -oE 'taler[^"[:space:]]{0,40}|GNU Taler|libeufin|gnunet' "$body" 2>/dev/null | head -3 | tr '\n' ' ' || true)
|
||||
# config JSON version if /config works
|
||||
local cfg_ver=""
|
||||
local ccode
|
||||
ccode=$(curl -skS -m "$HTTP_TIMEOUT" -o "$body" -w '%{http_code}' \
|
||||
"https://${h}/config" 2>/dev/null || echo 000)
|
||||
if [ "$ccode" = "200" ]; then
|
||||
cfg_ver=$(python3 -c 'import json,sys
|
||||
try:
|
||||
d=json.load(open(sys.argv[1]))
|
||||
print(d.get("version") or d.get("name") or d.get("currency") or "config-json")
|
||||
except Exception:
|
||||
print("")' "$body" 2>/dev/null || true)
|
||||
fi
|
||||
rm -f "$hdr" "$body"
|
||||
printf 'code=%s server=%s powered=%s cert_end=%s cfg=%s hint=%s' \
|
||||
"$code" "${server:-}" "${via:-}" "${cert_end:-}" "${cfg_ver:-}" "${taler_hint:-}"
|
||||
# return 0 if HTTP answered (any 2xx/3xx/4xx — service is up)
|
||||
case "$code" in
|
||||
2??|3??|4??) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
probe_http() {
|
||||
local h="$1" port="${2:-80}"
|
||||
local url code
|
||||
url="http://${h}/"
|
||||
[ "$port" != "80" ] && url="http://${h}:${port}/"
|
||||
code=$(curl -sS -m "$HTTP_TIMEOUT" -o /dev/null -w '%{http_code}' \
|
||||
--connect-timeout "$PORT_TIMEOUT" "$url" 2>/dev/null || echo 000)
|
||||
printf 'code=%s' "$code"
|
||||
case "$code" in 2??|3??|4??) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
probe_ssh_banner() {
|
||||
local h="$1" port="${2:-22}"
|
||||
local ban
|
||||
ban=$(timeout "$PORT_TIMEOUT" bash -c "exec 3<>/dev/tcp/${h}/${port}; dd bs=256 count=1 <&3 2>/dev/null" 2>/dev/null \
|
||||
| tr -d '\r' | head -1 || true)
|
||||
printf 'banner=%s' "${ban:-}"
|
||||
[ -n "$ban" ]
|
||||
}
|
||||
|
||||
# Map Server header → package name guess for OSV
|
||||
guess_package() {
|
||||
local server="$1"
|
||||
local s
|
||||
s=$(printf '%s' "$server" | tr 'A-Z' 'a-z')
|
||||
case "$s" in
|
||||
*nginx*) echo "nginx" ;;
|
||||
*apache*|*httpd*) echo "apache" ;;
|
||||
*caddy*) echo "caddy" ;;
|
||||
*openbsd\ httpd*) echo "openbsd-httpd" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
extract_version() {
|
||||
local server="$1"
|
||||
# nginx/1.22.1 → 1.22.1
|
||||
printf '%s' "$server" | sed -n 's/.*\/\([0-9][0-9.]*\).*/\1/p' | head -1
|
||||
}
|
||||
|
||||
# OSV / CVE check for software version taken from Server headers.
|
||||
#
|
||||
# Important: headers only expose upstream versions (nginx/1.26.3), never the
|
||||
# Debian package revision (1.26.3-3+deb13u7). Querying OSV ecosystem=Debian
|
||||
# with the bare version yields massive false positives (ancient DEBIAN-CVE-*
|
||||
# with introduced:0 and no fixed event, plus every package revision that
|
||||
# merely *starts with* 1.26.3).
|
||||
#
|
||||
# Policy:
|
||||
# - bare upstream version → upstream SEMVER signal only (WARN by default)
|
||||
# - full Debian package version (contains '-') → Debian OSV, filtered
|
||||
# - SURFACE_CVE=0 disables; SURFACE_CVE_LEVEL=error|warn (default warn for bare)
|
||||
check_cves() {
|
||||
local pkg="$1" ver="$2" host="$3"
|
||||
[ "$SURFACE_CVE" = "1" ] || return 0
|
||||
[ -n "$pkg" ] && [ -n "$ver" ] || return 0
|
||||
local level out rc
|
||||
# bare header versions are approximate → WARN unless overridden
|
||||
if [[ "$ver" == *-* ]] || [[ "$ver" == *+* ]]; then
|
||||
level="${SURFACE_CVE_LEVEL:-error}"
|
||||
else
|
||||
level="${SURFACE_CVE_LEVEL:-warn}"
|
||||
fi
|
||||
out=$(
|
||||
SURFACE_CVE_PKG="$pkg" SURFACE_CVE_VER="$ver" SURFACE_CVE_HOST="$host" python3 - <<'PY' 2>/dev/null || true
|
||||
import json, os, re, urllib.request
|
||||
|
||||
pkg = os.environ.get("SURFACE_CVE_PKG", "")
|
||||
ver = os.environ.get("SURFACE_CVE_VER", "")
|
||||
host = os.environ.get("SURFACE_CVE_HOST", "")
|
||||
|
||||
def ver_tuple(s: str):
|
||||
nums = [int(x) for x in re.findall(r"\d+", s or "")[:5]]
|
||||
return tuple(nums) if nums else ()
|
||||
|
||||
def vt_cmp(a, b):
|
||||
n = max(len(a), len(b))
|
||||
a = a + (0,) * (n - len(a))
|
||||
b = b + (0,) * (n - len(b))
|
||||
return (a > b) - (a < b)
|
||||
|
||||
def in_semver_events(ver, events):
|
||||
"""True if ver is in [introduced, fixed) for SEMVER-like event list."""
|
||||
vt = ver_tuple(ver)
|
||||
if not vt:
|
||||
return False
|
||||
# process sequential introduced/fixed pairs
|
||||
intro = None
|
||||
for e in events or []:
|
||||
if "introduced" in e:
|
||||
intro = e.get("introduced")
|
||||
elif "fixed" in e or "last_affected" in e:
|
||||
fixed = e.get("fixed")
|
||||
last = e.get("last_affected")
|
||||
lo = ver_tuple("0" if intro in (None, "0") else str(intro))
|
||||
if vt_cmp(vt, lo) < 0:
|
||||
intro = None
|
||||
continue
|
||||
if fixed is not None:
|
||||
if vt_cmp(vt, ver_tuple(str(fixed))) < 0:
|
||||
return True
|
||||
elif last is not None:
|
||||
if vt_cmp(vt, ver_tuple(str(last))) <= 0:
|
||||
return True
|
||||
intro = None
|
||||
# open-ended introduced without fixed → ignore (never clears; Debian noise)
|
||||
return False
|
||||
|
||||
def osv_post(body):
|
||||
req = urllib.request.Request(
|
||||
"https://api.osv.dev/v1/query",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
return json.load(r)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def osv_get(vid):
|
||||
try:
|
||||
with urllib.request.urlopen(f"https://api.osv.dev/v1/vulns/{vid}", timeout=15) as r:
|
||||
return json.load(r)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
bare = not (("-" in ver) or ("+" in ver))
|
||||
hits = []
|
||||
|
||||
if bare:
|
||||
# Upstream signal via CVE records that carry extracted_events for this package
|
||||
# (Server header has no Debian revision — do NOT use ecosystem=Debian).
|
||||
seed = {
|
||||
"nginx": [
|
||||
"CVE-2025-23419",
|
||||
"CVE-2025-53859",
|
||||
"CVE-2024-7347",
|
||||
"CVE-2024-34161",
|
||||
"CVE-2024-32760",
|
||||
"CVE-2024-31079",
|
||||
"CVE-2024-24989",
|
||||
"CVE-2024-24990",
|
||||
],
|
||||
"apache": [
|
||||
"CVE-2024-38474",
|
||||
"CVE-2024-38476",
|
||||
"CVE-2024-38477",
|
||||
"CVE-2023-31122",
|
||||
"CVE-2023-43622",
|
||||
],
|
||||
"caddy": [
|
||||
"CVE-2022-29718",
|
||||
"CVE-2023-50463",
|
||||
],
|
||||
}.get(pkg, [])
|
||||
for vid in seed:
|
||||
doc = osv_get(vid)
|
||||
if not doc:
|
||||
continue
|
||||
ok_hit = False
|
||||
for a in doc.get("affected") or []:
|
||||
# prefer extracted_events (human SEMVER) on GIT/nginx ranges
|
||||
for rg in a.get("ranges") or []:
|
||||
db = rg.get("database_specific") or {}
|
||||
extracted = db.get("extracted_events") or []
|
||||
if extracted and in_semver_events(ver, extracted):
|
||||
# package name filter when present
|
||||
cpes = db.get("cpe") or []
|
||||
if isinstance(cpes, str):
|
||||
cpes = [cpes]
|
||||
blob = json.dumps(a).lower() + json.dumps(cpes).lower()
|
||||
if pkg == "nginx" and "nginx" not in blob and "f5" not in blob:
|
||||
continue
|
||||
ok_hit = True
|
||||
break
|
||||
if rg.get("type") == "SEMVER" and in_semver_events(ver, rg.get("events") or []):
|
||||
ok_hit = True
|
||||
break
|
||||
if ok_hit:
|
||||
break
|
||||
if ok_hit:
|
||||
hits.append(vid)
|
||||
else:
|
||||
# Full package version — Debian ecosystem is meaningful
|
||||
data = osv_post({"package": {"name": pkg, "ecosystem": "Debian"}, "version": ver})
|
||||
for v in data.get("vulns") or []:
|
||||
vid = v.get("id") or "?"
|
||||
actionable = False
|
||||
for a in v.get("affected") or []:
|
||||
for rg in a.get("ranges") or []:
|
||||
events = rg.get("events") or []
|
||||
has_end = any(("fixed" in e) or ("last_affected" in e) for e in events)
|
||||
if not has_end:
|
||||
continue # open-ended Debian noise
|
||||
if rg.get("type") in ("ECOSYSTEM", "SEMVER"):
|
||||
# version string is full deb version; trust OSV query match
|
||||
# but only if a fixed event exists (actionable)
|
||||
actionable = True
|
||||
break
|
||||
if actionable:
|
||||
break
|
||||
# exact version listed
|
||||
versions = a.get("versions") or []
|
||||
if ver in versions:
|
||||
actionable = True
|
||||
break
|
||||
if actionable:
|
||||
hits.append(vid)
|
||||
|
||||
# de-dup preserve order
|
||||
seen = set()
|
||||
uniq = []
|
||||
for h in hits:
|
||||
if h not in seen:
|
||||
seen.add(h)
|
||||
uniq.append(h)
|
||||
|
||||
if uniq:
|
||||
print(f"HIT {len(uniq)} " + ",".join(uniq[:12]))
|
||||
else:
|
||||
print("CLEAN")
|
||||
PY
|
||||
)
|
||||
rc=0
|
||||
case "$out" in
|
||||
HIT\ *)
|
||||
local n ids
|
||||
n=$(printf '%s' "$out" | awk '{print $2}')
|
||||
ids=$(printf '%s' "$out" | cut -d' ' -f3-)
|
||||
if [ "$level" = "error" ]; then
|
||||
err "cve" "$host $pkg $ver — $n actionable vuln(s)" "$ids"
|
||||
rc=1
|
||||
else
|
||||
warn "cve" "$host $pkg $ver — $n actionable vuln(s) (header version · not Debian pkg)" "$ids"
|
||||
rc=0
|
||||
fi
|
||||
;;
|
||||
CLEAN)
|
||||
if [[ "$ver" == *-* ]] || [[ "$ver" == *+* ]]; then
|
||||
info "cve" "$host $pkg $ver — OSV clean (Debian package version)"
|
||||
else
|
||||
info "cve" "$host $pkg $ver — no actionable upstream CVE for bare Server-header version"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
info "cve" "$host $pkg $ver — CVE probe skipped/unavailable"
|
||||
;;
|
||||
esac
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
cert_expiry_check() {
|
||||
local h="$1" end="$2"
|
||||
[ -n "$end" ] || return 0
|
||||
local end_epoch now left days
|
||||
end_epoch=$(date -d "$end" +%s 2>/dev/null || date -j -f "%b %e %T %Y %Z" "$end" +%s 2>/dev/null || echo 0)
|
||||
now=$(date +%s)
|
||||
[ "$end_epoch" -gt 0 ] || return 0
|
||||
left=$((end_epoch - now))
|
||||
days=$((left / 86400))
|
||||
if [ "$left" -le 0 ]; then
|
||||
err "tls" "$h certificate EXPIRED" "notAfter=$end"
|
||||
return 1
|
||||
fi
|
||||
if [ "$days" -le 14 ]; then
|
||||
warn "tls" "$h certificate expires in ${days}d" "notAfter=$end"
|
||||
else
|
||||
info "tls" "$h cert ok · ${days}d left · notAfter=$end"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# nmap OS / service fingerprint (v1.8.0+).
|
||||
# ERROR when fingerprint indicates an OS that is EOL / should be upgraded.
|
||||
# Soft/info when nmap missing or unprivileged OS scan fails.
|
||||
nmap_fingerprint_host() {
|
||||
local h="$1"
|
||||
local xml out osline accuracy eol=0
|
||||
[ "${SURFACE_NMAP:-1}" = "1" ] || return 0
|
||||
if ! command -v nmap >/dev/null 2>&1; then
|
||||
warn "nmap" "$h · nmap not installed (apt install nmap) — skip OS fingerprint"
|
||||
return 0
|
||||
fi
|
||||
xml=$(mktemp)
|
||||
out=$(mktemp)
|
||||
# Prefer OS detection when allowed; always try service version light
|
||||
if [ "${SURFACE_NMAP_OS:-1}" = "1" ] && {
|
||||
nmap -Pn -n -T4 -O --osscan-guess --max-os-tries 1 --host-timeout 45s \
|
||||
-p 22,80,443,25,465,587,993 "$h" -oX "$xml" >"$out" 2>&1
|
||||
}; then
|
||||
:
|
||||
else
|
||||
nmap -Pn -n -T4 -sV --version-light --host-timeout 35s \
|
||||
-p 22,80,443,25,465,587,993 "$h" -oX "$xml" >"$out" 2>&1 || true
|
||||
fi
|
||||
# Parse best OS match from XML
|
||||
osline=$(
|
||||
python3 - "$xml" <<'PY' 2>/dev/null || true
|
||||
import sys, xml.etree.ElementTree as ET
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
best = None
|
||||
best_acc = -1
|
||||
for osm in root.findall(".//osmatch"):
|
||||
name = osm.get("name") or ""
|
||||
try:
|
||||
acc = int(osm.get("accuracy") or "0")
|
||||
except ValueError:
|
||||
acc = 0
|
||||
if name and acc >= best_acc:
|
||||
best_acc = acc
|
||||
best = name
|
||||
if best:
|
||||
print(f"{best_acc}|{best}")
|
||||
# also surface product/version from service table
|
||||
svcs = []
|
||||
for port in root.findall(".//port"):
|
||||
state = (port.find("state").get("state") if port.find("state") is not None else "")
|
||||
if state != "open":
|
||||
continue
|
||||
svc = port.find("service")
|
||||
if svc is None:
|
||||
continue
|
||||
prod = svc.get("product") or svc.get("name") or ""
|
||||
ver = svc.get("version") or ""
|
||||
if prod:
|
||||
svcs.append(f"{prod} {ver}".strip())
|
||||
if svcs:
|
||||
print("SVC|" + "; ".join(svcs[:8]))
|
||||
PY
|
||||
)
|
||||
rm -f "$xml" "$out"
|
||||
if [ -z "$osline" ]; then
|
||||
info "nmap" "$h · no OS/service fingerprint (need root for -O, or host filtered)"
|
||||
return 0
|
||||
fi
|
||||
local osname="" svcinfo=""
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
SVC\|*) svcinfo=${line#SVC|} ;;
|
||||
*\|*)
|
||||
accuracy=${line%%|*}
|
||||
osname=${line#*|}
|
||||
ok "nmap-os" "$h · OS guess ${accuracy}% · $osname"
|
||||
;;
|
||||
esac
|
||||
done <<<"$osline"
|
||||
[ -n "$svcinfo" ] && info "nmap-svc" "$h · $svcinfo"
|
||||
|
||||
# EOL / upgrade-required OS fingerprints → ERROR
|
||||
case "$(printf '%s' "$osname" | tr 'A-Z' 'a-z')" in
|
||||
*debian*6*|*debian*7*|*debian*8*|*debian*9*|*debian*10*|*debian*\"buster\"*|*debian*buster*)
|
||||
eol=1 ;;
|
||||
*ubuntu*12.04*|*ubuntu*14.04*|*ubuntu*16.04*|*ubuntu*18.04*|*ubuntu*20.04*)
|
||||
eol=1 ;;
|
||||
*centos*[5-7]*|*red\ hat\ enterprise*5*|*red\ hat\ enterprise*6*|*red\ hat\ enterprise*7*)
|
||||
eol=1 ;;
|
||||
*windows\ server\ 2008*|*windows\ server\ 2012*|*windows\ xp*|*windows\ 7*)
|
||||
eol=1 ;;
|
||||
*freebsd\ 1[0-2]*|*freebsd\ [0-9].*)
|
||||
eol=1 ;;
|
||||
esac
|
||||
if [ "$eol" = "1" ]; then
|
||||
err "nmap-upgrade" "$h OS fingerprint indicates EOL / upgrade required" "$osname"
|
||||
return 1
|
||||
fi
|
||||
if [ -n "$osname" ]; then
|
||||
info "nmap-upgrade" "$h OS fingerprint not on EOL list · $osname"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
scan_host() {
|
||||
local h="$1"
|
||||
local ports proto label expect
|
||||
local p open_ports=() any_proto_ok=0 ping_ok=0 dns=0
|
||||
local probe_detail server_hdr pkg ver
|
||||
|
||||
ports=${HOST_PORTS[$h]:-443}
|
||||
proto=${HOST_PROTO[$h]:-https}
|
||||
label=${HOST_LABEL[$h]:-$h}
|
||||
expect=${HOST_EXPECT[$h]:-0}
|
||||
|
||||
set_group "$label"
|
||||
|
||||
if dns_ok "$h"; then
|
||||
dns=1
|
||||
ok "dns" "$h resolves"
|
||||
else
|
||||
if [ "$expect" = "1" ]; then
|
||||
err "dns" "$h does not resolve (catalogued)"
|
||||
else
|
||||
info "dns" "$h no resolve (optional guess) — skip"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ping_host "$h"; then
|
||||
ping_ok=1
|
||||
info "ping" "$h ICMP ok"
|
||||
else
|
||||
info "ping" "$h ICMP no reply (not decisive)"
|
||||
fi
|
||||
|
||||
# port scan (unique ports)
|
||||
local _seen_ports=" "
|
||||
for p in ${ports//,/ }; do
|
||||
[ -n "$p" ] || continue
|
||||
case "$_seen_ports" in *" $p "*) continue ;; esac
|
||||
_seen_ports="$_seen_ports$p "
|
||||
if tcp_open "$h" "$p"; then
|
||||
open_ports+=("$p")
|
||||
info "port" "$h:$p open (TCP)"
|
||||
else
|
||||
info "port" "$h:$p closed/filtered (TCP)"
|
||||
fi
|
||||
done
|
||||
|
||||
# Protocol verification on open ports (and always try 443/80 for https/http expect)
|
||||
local try_ports=("${open_ports[@]}")
|
||||
if [ ${#try_ports[@]} -eq 0 ]; then
|
||||
# still try expected protocol ports even if scan said closed (scan false negatives)
|
||||
case "$proto" in
|
||||
https) try_ports=(443) ;;
|
||||
http) try_ports=(80) ;;
|
||||
ssh) try_ports=(22) ;;
|
||||
*) try_ports=(443 80) ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
server_hdr=""
|
||||
ver=""
|
||||
for p in "${try_ports[@]}"; do
|
||||
case "$p" in
|
||||
443|8443)
|
||||
if probe_detail=$(probe_https "$h" "$p"); then
|
||||
any_proto_ok=1
|
||||
ok "https" "$h:$p up · $probe_detail"
|
||||
server_hdr=$(printf '%s' "$probe_detail" | sed -n 's/.*server=\([^ ]*\).*/\1/p')
|
||||
# cert
|
||||
local cend
|
||||
cend=$(printf '%s' "$probe_detail" | sed -n 's/.*cert_end=\([^ ]*\).*/\1/p')
|
||||
cert_expiry_check "$h" "$cend" || true
|
||||
local cfg
|
||||
cfg=$(printf '%s' "$probe_detail" | sed -n 's/.*cfg=\([^ ]*\).*/\1/p')
|
||||
if [ -n "$cfg" ]; then
|
||||
info "version" "$h config/api hint: $cfg"
|
||||
ver="$cfg"
|
||||
fi
|
||||
else
|
||||
info "https" "$h:$p no HTTP response · ${probe_detail:-}"
|
||||
fi
|
||||
;;
|
||||
80|8080)
|
||||
if probe_detail=$(probe_http "$h" "$p"); then
|
||||
any_proto_ok=1
|
||||
ok "http" "$h:$p up · $probe_detail"
|
||||
else
|
||||
info "http" "$h:$p no HTTP response"
|
||||
fi
|
||||
;;
|
||||
22)
|
||||
if probe_detail=$(probe_ssh_banner "$h" "$p"); then
|
||||
any_proto_ok=1
|
||||
ok "ssh" "$h:$p banner · $probe_detail"
|
||||
ver=$(printf '%s' "$probe_detail" | sed 's/banner=//')
|
||||
else
|
||||
info "ssh" "$h:$p no SSH banner"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
if tcp_open "$h" "$p"; then
|
||||
info "tcp" "$h:$p open · protocol unknown"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Version summary
|
||||
if [ -n "$server_hdr" ]; then
|
||||
info "server-header" "$h · $server_hdr"
|
||||
pkg=$(guess_package "$server_hdr")
|
||||
ver_soft=$(extract_version "$server_hdr")
|
||||
if [ -n "$pkg" ] && [ -n "$ver_soft" ]; then
|
||||
info "version" "$h software $pkg $ver_soft (from Server header)"
|
||||
check_cves "$pkg" "$ver_soft" "$h" || true
|
||||
elif [ -n "$server_hdr" ]; then
|
||||
warn "version" "$h Server header present but version unknown · $server_hdr"
|
||||
fi
|
||||
elif [ -n "$ver" ]; then
|
||||
info "version" "$h · $ver"
|
||||
else
|
||||
if [ "$any_proto_ok" = "1" ]; then
|
||||
warn "version" "$h reachable but software version unknown"
|
||||
fi
|
||||
fi
|
||||
|
||||
# nmap OS / service fingerprint (v1.8.0+) — ERROR on EOL OS
|
||||
nmap_fingerprint_host "$h" || true
|
||||
|
||||
# Expected service must answer protocol (not just ping/port)
|
||||
# Note: ${array[*]:-x} is NOT valid default syntax (bash treats : as slice).
|
||||
local ports_txt="${open_ports[*]}"
|
||||
ports_txt=${ports_txt:-none}
|
||||
if [ "$expect" = "1" ]; then
|
||||
if [ "$any_proto_ok" = "1" ]; then
|
||||
ok "reachability" "$h catalogued service OK (protocol confirmed)"
|
||||
else
|
||||
err "reachability" "$h catalogued but not reachable via ${proto}/protocol" \
|
||||
"dns=$dns ping=$ping_ok open_ports=${ports_txt} (ICMP/TCP alone not enough; protocol failed)"
|
||||
fi
|
||||
else
|
||||
if [ "$any_proto_ok" = "1" ]; then
|
||||
info "reachability" "$h optional host responds"
|
||||
else
|
||||
info "reachability" "$h optional · no protocol response (ok)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- main ---
|
||||
# Scope (remote only):
|
||||
# ./taler-monitoring.sh surface → full ecosystem catalog
|
||||
# ./taler-monitoring.sh -d hacktivism.ch surface → only that domain
|
||||
if [ "${SURFACE_SCOPE:-}" = "ecosystem" ]; then
|
||||
load_catalog
|
||||
expand_ecosystem_scope
|
||||
elif [ "${SURFACE_SCOPE:-}" = "domain" ] || [ "${TALER_DOMAIN_FROM_CLI:-0}" = "1" ]; then
|
||||
# domain mode: do not load whole ecosystem first
|
||||
CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}"
|
||||
expand_domain_scope "${TALER_DOMAIN:-hacktivism.ch}"
|
||||
elif [ "${SURFACE_SCOPE:-auto}" = "auto" ]; then
|
||||
# no -d → ecosystem; with -d → domain (TALER_DOMAIN_FROM_CLI)
|
||||
if [ "${TALER_DOMAIN_FROM_CLI:-0}" = "1" ]; then
|
||||
CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}"
|
||||
expand_domain_scope "$TALER_DOMAIN"
|
||||
else
|
||||
load_catalog
|
||||
expand_ecosystem_scope
|
||||
fi
|
||||
else
|
||||
load_catalog
|
||||
expand_ecosystem_scope
|
||||
fi
|
||||
|
||||
info "inventory" "${#ORDERED_HOSTS[@]} hosts to probe (remote only · no SSH · no local podman on targets)"
|
||||
info "flags" "SURFACE_CVE=${SURFACE_CVE:-1} SURFACE_NMAP=${SURFACE_NMAP:-1} SURFACE_NMAP_OS=${SURFACE_NMAP_OS:-1} PORT_TIMEOUT=${PORT_TIMEOUT:-2} HTTP_TIMEOUT=${HTTP_TIMEOUT:-12}"
|
||||
|
||||
for h in "${ORDERED_HOSTS[@]}"; do
|
||||
[ -n "$h" ] || continue
|
||||
scan_host "$h" || true
|
||||
done
|
||||
|
||||
# mon_disk on the *runner* only (optional) — not remote servers' disks
|
||||
if [ "${SURFACE_CHECK_RUNNER_DISK:-0}" = "1" ]; then
|
||||
set_group disk
|
||||
if declare -F mon_disk_check_host >/dev/null 2>&1; then
|
||||
mon_disk_check_host "runner" || true
|
||||
else
|
||||
warn "disk" "mon_disk_check_host not available"
|
||||
fi
|
||||
fi
|
||||
|
||||
# summary returns non-zero when FAIL_N>0; do not trip set -e mid-script
|
||||
summary || true
|
||||
if [ "${FAIL_N:-0}" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
Loading…
Add table
Add a link
Reference in a new issue