← All news

How to monitor your server’s exposure with cron and get alerted when it changes

 ·  How-to

A one-off scan tells you what is exposed right now. What you usually want to know is when that changes — a port that opened after a package upgrade, a service that started answering on a new interface, a CVE that appeared against a version you were already running.

This is a small bash script that runs a qsa.sh scan on a schedule, remembers the last result, and notifies you only when something actually changed. No agent, no account, no dependencies beyond bash, curl and diff.

How it works

  • Runs the scan in plain-text mode (?nocolor=1) so the output is clean enough to store, diff and email.
  • Saves every run under ~/.qsa-monitor/, keeping the last 30.
  • Strips the parts that legitimately differ every run — durations, timestamps, certificate countdowns — before comparing, so an unchanged server stays silent.
  • Notifies through whichever method you configure at the top of the file.

Free tokens and paid tokens both work. With no token it runs the free scan (top 1,000 ports, one scan per day). With a Pro or Deep token it sweeps all 65,535 ports; the script handles the asynchronous flow those tiers use, polling the single-read results URL until the report is ready.

The script

Save it as /usr/local/bin/qsa-monitor.sh, then chmod +x /usr/local/bin/qsa-monitor.sh. Everything you need to change is in the CONFIG block at the top.

bash
#!/usr/bin/env bash
#
# qsa-monitor.sh — run a qsa.sh scan on a schedule, and tell me when my exposure CHANGES.
#
# Runs the scan in plain-text mode, stores the result, compares it with the previous run
# and notifies you only when something actually changed (new open port, new CVE, a service
# version moving). Volatile lines (durations, timestamps) are stripped before comparing so
# an unchanged server stays quiet.
#
# Requires: bash, curl, diff. Nothing else.

set -uo pipefail

# ─────────────────────────── CONFIG ───────────────────────────

# Your qsa.sh token. Leave EMPTY for the free scan (top 1,000 ports, 1 scan/24h).
# A Pro token scans all 65,535 ports; a Deep token adds the full nuclei set.
QSA_TOKEN=""

# When to notify:  change = only when the result differs (recommended)
#                  always = every run
#                  never  = never notify, just keep the history
NOTIFY_ON="change"

# How to notify:   none | email | webhook | slack | ntfy | command
NOTIFY_METHOD="none"

# --- per-method settings (only the one you chose is used) ---
EMAIL_TO=""                  # email:   where to send (needs a working `mail`/`sendmail`, or use api below)
EMAIL_FROM="qsa-monitor@$(hostname -f 2>/dev/null || hostname)"

WEBHOOK_URL=""               # webhook: any endpoint that accepts a JSON POST
SLACK_WEBHOOK_URL=""         # slack:   https://hooks.slack.com/services/...
NTFY_TOPIC_URL=""            # ntfy:    https://ntfy.sh/your-topic

# api: send through a transactional email provider (see the post for options)
API_URL=""                   # e.g. https://api.mailersend.com/v1/email
API_TOKEN=""
API_FROM=""                  # a verified sender address at your provider

# command: run any command you like; the report is piped to it on stdin
NOTIFY_COMMAND=""            # e.g. "/usr/local/bin/my-alerter --title qsa"

# Where run history lives. Each run is kept so you can diff any two.
STATE_DIR="${HOME}/.qsa-monitor"

# Keep this many past runs (older ones are pruned).
KEEP_RUNS=30

# ──────────────────────── END OF CONFIG ───────────────────────

BASE="https://qsa.sh"
mkdir -p "$STATE_DIR" || { echo "qsa-monitor: cannot create $STATE_DIR" >&2; exit 1; }

STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
CURRENT="$STATE_DIR/scan-$STAMP.txt"
LATEST="$STATE_DIR/latest.txt"

log() { printf '%s qsa-monitor: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >&2; }

# ── 1. Run the scan ───────────────────────────────────────────
# nocolor=1 gives clean plain text with no ANSI escapes — the right mode for files,
# logs and email. Free scans stream the result directly. Token scans are asynchronous:
# they return a one-time results URL that we poll until the report is ready.
fetch_scan() {
  if [ -z "$QSA_TOKEN" ]; then
    curl -fsS --max-time 600 "$BASE/?nocolor=1"
    return $?
  fi

  local queued rurl deadline
  queued="$(curl -fsS --max-time 120 "$BASE/$QSA_TOKEN?nocolor=1")" || return 1

  # The queue response contains the single-read results URL.
  rurl="$(printf '%s' "$queued" | grep -oE "$BASE/r/[a-f0-9]{32}" | head -1)"
  if [ -z "$rurl" ]; then
    printf '%s\n' "$queued"          # not a queue response (error/refusal) — keep it as the result
    return 0
  fi

  # Poll until the report replaces the "still running" holding page. The results link is
  # SINGLE-READ: the first successful fetch consumes it, so capture it in one go.
  deadline=$(( $(date +%s) + 1200 ))
  while [ "$(date +%s)" -lt "$deadline" ]; do
    sleep 20
    local body
    body="$(curl -fsS --max-time 120 "$rurl")" || continue
    case "$body" in
      *"still running"*) continue ;;
      *) printf '%s\n' "$body"; return 0 ;;
    esac
  done
  log "timed out waiting for $rurl"
  return 1
}

if ! fetch_scan > "$CURRENT"; then
  log "scan failed; keeping previous state"
  rm -f "$CURRENT"
  exit 1
fi
if [ ! -s "$CURRENT" ]; then
  log "empty scan output; keeping previous state"
  rm -f "$CURRENT"
  exit 1
fi

# ── 2. Normalise ──────────────────────────────────────────────
# Strip everything that legitimately differs run to run, so only real changes to your
# exposure show up as a diff.
normalise() {
  sed -E \
    -e '/^[[:space:]]*(Duration|Scan time|Scan complete|stage timing)/d' \
    -e '/Running for /d' \
    -e '/cert expires in /d' \
    -e '/^[[:space:]]*Re-run any time/d' \
    -e '/Results are ephemeral/d' \
    -e 's/[0-9]+(\.[0-9]+)?s\b//g' \
    "$1" | sed -E 's/[[:space:]]+$//' | grep -v '^[[:space:]]*$'
}

CHANGED=0
if [ -f "$LATEST" ]; then
  if ! diff -q <(normalise "$LATEST") <(normalise "$CURRENT") >/dev/null 2>&1; then
    CHANGED=1
  fi
else
  CHANGED=1        # first ever run
fi

DIFF_TEXT=""
if [ "$CHANGED" -eq 1 ] && [ -f "$LATEST" ]; then
  DIFF_TEXT="$(diff -u <(normalise "$LATEST") <(normalise "$CURRENT") | sed '1,2d')"
fi

# ── 3. Notify ─────────────────────────────────────────────────
HOSTNAME_FQDN="$(hostname -f 2>/dev/null || hostname)"
if [ "$CHANGED" -eq 1 ]; then
  SUBJECT="qsa.sh: exposure CHANGED on $HOSTNAME_FQDN"
else
  SUBJECT="qsa.sh: no change on $HOSTNAME_FQDN"
fi

BODY="$SUBJECT
Scanned: $(date -u +'%Y-%m-%d %H:%M UTC')

$( [ -n "$DIFF_TEXT" ] && printf 'WHAT CHANGED\n%s\n\n' "$DIFF_TEXT" )
FULL REPORT
$(cat "$CURRENT")"

should_notify() {
  case "$NOTIFY_ON" in
    always) return 0 ;;
    never)  return 1 ;;
    change) [ "$CHANGED" -eq 1 ] && return 0 || return 1 ;;
    *)      return 1 ;;
  esac
}

json_escape() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null \
                || printf '"%s"' "$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr '\n' ' ')"; }

notify() {
  case "$NOTIFY_METHOD" in
    none) : ;;
    email)
      [ -n "$EMAIL_TO" ] || { log "EMAIL_TO not set"; return 1; }
      printf '%s\n' "$BODY" | mail -s "$SUBJECT" -r "$EMAIL_FROM" "$EMAIL_TO"
      ;;
    webhook)
      [ -n "$WEBHOOK_URL" ] || { log "WEBHOOK_URL not set"; return 1; }
      curl -fsS --max-time 30 -X POST "$WEBHOOK_URL" \
        -H 'Content-Type: application/json' \
        -d "{\"host\":$(json_escape "$HOSTNAME_FQDN"),\"changed\":$([ "$CHANGED" -eq 1 ] && echo true || echo false),\"subject\":$(json_escape "$SUBJECT"),\"report\":$(json_escape "$BODY")}" >/dev/null
      ;;
    slack)
      [ -n "$SLACK_WEBHOOK_URL" ] || { log "SLACK_WEBHOOK_URL not set"; return 1; }
      curl -fsS --max-time 30 -X POST "$SLACK_WEBHOOK_URL" \
        -H 'Content-Type: application/json' \
        -d "{\"text\":$(json_escape "$SUBJECT"$'\n```\n'"${DIFF_TEXT:-no diff}"$'\n```')}" >/dev/null
      ;;
    ntfy)
      [ -n "$NTFY_TOPIC_URL" ] || { log "NTFY_TOPIC_URL not set"; return 1; }
      printf '%s\n' "$BODY" | curl -fsS --max-time 30 -H "Title: $SUBJECT" --data-binary @- "$NTFY_TOPIC_URL" >/dev/null
      ;;
    api)
      [ -n "$API_URL" ] && [ -n "$API_TOKEN" ] && [ -n "$API_FROM" ] || { log "API_* not set"; return 1; }
      curl -fsS --max-time 30 -X POST "$API_URL" \
        -H "Authorization: Bearer $API_TOKEN" \
        -H 'Content-Type: application/json' \
        -d "{\"from\":{\"email\":$(json_escape "$API_FROM")},\"to\":[{\"email\":$(json_escape "$EMAIL_TO")}],\"subject\":$(json_escape "$SUBJECT"),\"text\":$(json_escape "$BODY")}" >/dev/null
      ;;
    command)
      [ -n "$NOTIFY_COMMAND" ] || { log "NOTIFY_COMMAND not set"; return 1; }
      printf '%s\n' "$BODY" | sh -c "$NOTIFY_COMMAND"
      ;;
    *) log "unknown NOTIFY_METHOD: $NOTIFY_METHOD"; return 1 ;;
  esac
}

if should_notify; then
  notify || log "notification failed (scan itself was fine)"
fi

# ── 4. Save state + prune ─────────────────────────────────────
cp -f "$CURRENT" "$LATEST"
ls -1t "$STATE_DIR"/scan-*.txt 2>/dev/null | tail -n +"$((KEEP_RUNS + 1))" | xargs -r rm -f

[ "$CHANGED" -eq 1 ] && log "exposure CHANGED" || log "no change"
exit 0

Schedule it

The free tier allows one scan per IP per day, so daily is the right cadence for it. Pick a quiet minute rather than the top of the hour — every cron on the internet fires at 0.

cron
# Daily at 04:17, free scan, notify only when the exposure changes
17 4 * * * /usr/local/bin/qsa-monitor.sh >> /var/log/qsa-monitor.log 2>&1

With a Pro token you can afford to look more often. Hourly is reasonable:

cron
# Hourly at :17 with a Pro token (all 65,535 ports)
17 * * * * /usr/local/bin/qsa-monitor.sh >> /var/log/qsa-monitor.log 2>&1

Install it with crontab -e, or drop it in /etc/cron.d/qsa-monitor (that file needs a user field):

cron
17 4 * * * root /usr/local/bin/qsa-monitor.sh >> /var/log/qsa-monitor.log 2>&1

Run it once by hand first. The first run has nothing to compare against, so it will report a change and store the baseline — that is expected.

bash
/usr/local/bin/qsa-monitor.sh
cat ~/.qsa-monitor/latest.txt

Choosing a notification method

Set NOTIFY_METHOD at the top of the script.

  • webhook — the simplest option if you already run anything that accepts a JSON POST. You get host, changed, subject and the full report.
  • slack — paste an incoming-webhook URL into SLACK_WEBHOOK_URL.
  • ntfy — push to your phone with no account: set NTFY_TOPIC_URL to https://ntfy.sh/some-topic-you-choose.
  • command — the escape hatch. The report is piped to whatever you set in NOTIFY_COMMAND, so any existing alerting tool works.
  • email — uses the local mail command. Fine on a box that already has working mail.
  • api — posts to a transactional email provider, which is what you want if the server has no mail stack.

On email specifically

Sending mail from a random server is the part that usually breaks. Without SPF, DKIM and a warmed sending IP, alerts land in spam — which is worse than no alerts, because you will believe nothing has changed.

For anything you actually rely on, send through a provider. MailerSend has a free tier that comfortably covers a handful of change alerts a month, and works with the api method above — set API_URL to https://api.mailersend.com/v1/email, API_TOKEN to your key, and API_FROM to a verified sender. Postmark, Resend and Amazon SES all work the same way; adjust the JSON in the api branch to match their schema.

*(Disclosure: the MailerSend link is an affiliate link. It costs you nothing and we would suggest them regardless — but you should know.)*

A note on what this actually proves

The script tells you when your externally visible exposure changes. That is a genuinely useful signal and it is the one most monitoring misses, because most monitoring runs on the host and therefore sees what the host believes rather than what the internet can reach.

It is not a substitute for patching, host hardening or log review, and a free-tier run only covers the top 1,000 ports. If you want the full 65,535-port sweep on a schedule, that is what the Pro tier is for.

← All news  ·  Pricing  ·  How it works