Emergency server help: get in touch

VoIP Toll Fraud Watch: Alert on International and After-Hours Calls in Asterisk

A Bash script that reads the Asterisk or FreePBX call records every few minutes and raises an alarm on the patterns of toll fraud: bursts of international or premium-rate calls, long international talk time, calls outside office hours, one extension making far more calls than usual, and transit calls that come in on a trunk and go straight back out.

Version
1.0.1
Last updated
October 18, 2026
Language
Bash
Tested on
1.0.1 and 1.0.0 on Debian 12 with FreePBX 17.0.33 and Asterisk 22.11 (lab test, 16 Oct 2026): simulated international bursts, after-hours calls and normal domestic calls through a test carrier
Testing level
Lab-tested
License
MIT
Pricing
Free

Toll fraud usually happens out of hours: a stolen extension password or an open route is used to pump calls to premium and far-away international numbers, often dozens at once, until someone notices on Monday. Providers bill all of it. This VoIP toll fraud watch reads the call detail records (CDR) every few minutes and alerts you while it is happening, so a weekend attack costs a few calls instead of a few thousand.

It only reads the CDR. It never blocks a call or changes the PBX itself, so a false alarm cannot take your phones down.

Version 1.0.1 (17 October 2026): If the FreePBX CDR database could not be queried (a wrong password or a stopped MariaDB), 1.0.0 quietly fell back to the Master.csv file, which is often empty on FreePBX, and could report “nothing suspicious”. 1.0.1 stops with UNKNOWN (exit 3) instead, shows when the transit check is skipped, and retries alerts that could not be delivered. If you use 1.0.0, update.

What the VoIP toll fraud watch looks for

  • International and premium bursts: more than 5 international or premium calls in the time window (adjustable), with a breakdown by country code.
  • International talk time above 30 minutes in the window.
  • After-hours calls: with --hours 08-18, any outbound call outside office hours or office days.
  • One busy source: an extension or caller making more than 40 outbound calls in the window, a sign of a stolen phone password.
  • Transit fraud: calls that arrive on a trunk and leave on a trunk, which happens when a trunk lands in an internal context.
  • Destinations you call legitimately (your own office abroad, for example) can be excluded with --allow-regex.

Install and first run

curl -fsSLO https://scr.srvscripts.com/voip-toll-fraud-watch/voip-toll-fraud-watch.sh
bash voip-toll-fraud-watch.sh --minutes 60
bash voip-toll-fraud-watch.sh --minutes 15 --max-intl-calls 3 --hours 08-18

On FreePBX it reads asteriskcdrdb as root with no extra settings. On plain Asterisk it falls back to /var/log/asterisk/cdr-csv/Master.csv (GNU awk is needed for that). Exit codes: 0 nothing suspicious, 1 warning, 2 alarm, 3 could not read the CDR.

Tested on a real server

TestWhat we didResult
International burstMade 9 calls to 6 country codes (+44, +53, +234, +252, +882, +962) from two test extensions within a few minutesALARM “9 international/premium calls in 15 min (limit 5)”, exit 2
After hoursSame run with --hours 08-17, outside those hoursWARN for 10 outbound calls outside office hours
One busy extensionA SIPp load test placed 271 domestic calls from extension 1002 in 40 minutesWARN “source 1002 made 271 outbound calls (limit 40)”, exit 1
Normal volume for that siteSame window with --max-ext-calls 1000OK, exit 0
1.0.1: CDR database not readableRun with a wrong database passwordUNKNOWN “cannot query the CDR database”, exit 3

We ran it on our FreePBX 17 lab server (Debian 12, Asterisk 22.11, 4 vCPU) on 16 October 2026. The calls were real SIP calls through FreePBX routes to a test carrier (a SIPp process that answers every call), so they appear in the CDR exactly like real traffic; no real numbers were dialled. The screenshots are the real terminal output; only IP addresses are masked.

VoIP toll fraud watch: terminal output of bash voip-toll-fraud-watch.sh --minutes 15 --hours 08-17 on Debian 12 with FreePBX 17 and Asterisk 22.11
bash voip-toll-fraud-watch.sh --minutes 15 --hours 08-17 — exit code 2, 0.1 s. Debian 12, FreePBX 17.0, Asterisk 22.11, 16 Oct 2026. IP addresses masked.
VoIP toll fraud watch: terminal output of bash voip-toll-fraud-watch.sh --minutes 40 on Debian 12 with FreePBX 17 and Asterisk 22.11
bash voip-toll-fraud-watch.sh --minutes 40 — exit code 1, 0.1 s. Debian 12, FreePBX 17.0, Asterisk 22.11, 16 Oct 2026. IP addresses masked.

Run it from cron

Every 10 minutes, looking at the last 10 minutes, with one e-mail per new set of findings:

# /etc/cron.d/voip-toll-fraud-watch
*/10 * * * * root /usr/local/bin/voip-toll-fraud-watch.sh --minutes 10 --hours 08-18 --quiet --mail ops@example.com

Use --webhook to post to Slack, Discord or Teams instead; the JSON includes a ready-made text field.

If it fires

  • Disable the route or trunk in FreePBX (Connectivity > Outbound Routes) or ask your provider to block international calls on the account.
  • Change the password of the extension in the “top sources” list and check pjsip show contacts for IP addresses you do not recognise.
  • Look at the calls in the CDR (see Asterisk CDR reports from MySQL) and keep the records for the provider dispute.
  • Run the Asterisk security audit to find how they got in.

Options

OptionMeaning
--minutes NLook at calls that started in the last N minutes (default 60)
--max-intl-calls NAlarm above N international or premium calls (default 5)
--max-intl-minutes NAlarm above N minutes of international talk time (default 30)
--max-ext-calls NWarn when one source made more than N outbound calls (default 40)
--hours HH-HH, --days D-DOffice hours and days (Monday = 1, default days 1-5); outbound calls outside them are warnings
--intl-regex REWhat counts as international (default '^(\+|00|011|9011|900|9900)')
--allow-regex REDestinations that are never suspicious
--source db|csv|autoCDR source (default auto: the database if reachable, else Master.csv); --db NAME and --csv FILE override the defaults
--json, -qJSON output; quiet mode prints only when something is suspicious
--mail ADDR, --webhook URLSend alarms; by default only when the findings change
--every-timeSend on every suspicious run, not only on changes

Notes

  • Read-only: nothing to back up or revert. It keeps a small state file so repeated alerts are not sent for the same findings.
  • Tune the limits to your normal traffic. A call centre dialling abroad all day needs higher limits or an --allow-regex for its usual countries.
  • The script reacts after calls start. Prevention still matters: PINs on international routes, provider spend limits and fail2ban (see the toll fraud prevention checklist).

Related: PJSIP health check, fail2ban for Asterisk and FreePBX, dialplan pattern tester.

Background on telecom fraud types and losses: the Communications Fraud Control Association (CFCA).

The script

voip-toll-fraud-watch.shDownload
#!/usr/bin/env bash
# VoIP Toll Fraud Watch: Alert on International, Premium and After-Hours Calls from Asterisk/FreePBX CDR (v1.0.1) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/voip-toll-fraud-watch/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
#
# Reads the call detail records (FreePBX/Asterisk asteriskcdrdb.cdr in MariaDB/MySQL, or cdr-csv Master.csv) for the
# last N minutes and raises an alarm on the patterns seen in toll fraud: bursts of international or premium-rate
# calls, many minutes to one country, calls outside office hours, one extension making far more calls than usual,
# and calls that come in on a trunk and go straight back out (transit fraud). Run it from cron every 5-15 minutes
# with --mail or --webhook. It only reads the CDR; it never blocks anything itself (see --help for what to do).
#
#   bash voip-toll-fraud-watch.sh --minutes 60
#   bash voip-toll-fraud-watch.sh --minutes 15 --max-intl-calls 3 --hours 08-18 --mail ops@example.com
#   */10 * * * * root /usr/local/bin/voip-toll-fraud-watch.sh --minutes 10 --quiet --mail ops@example.com
#
# Exit codes: 0 nothing suspicious, 1 warning, 2 alarm, 3 could not read the CDR or bad option.
# Version 1.0.1 (2026-10-17): on FreePBX (or with --source db) a CDR database that cannot be queried is UNKNOWN (exit 3);
#   1.0.0 could fall back to an old Master.csv and report OK. A skipped transit check is shown. Notification state
#   is saved per channel only after delivery. Found in an independent review.
# Version 1.0.0 (2026-10-17): first release. Tested on FreePBX 17.0.33 / Asterisk 22.11 (Debian 12) with simulated
#   international bursts, after-hours calls and normal domestic traffic through a test carrier.
set -uo pipefail
export LC_ALL=C

MIN=60; MAX_INTL=5; MAX_INTL_MIN=30; MAX_EXT=40; HOURS=""; DAYS="1-5"; INTL_RE='^(\+|00|011|9011|900|9900)'
PREMIUM_RE='^1?(900|976)[0-9]{7}$'; ALLOW_RE=""; SOURCE=auto; CSV=/var/log/asterisk/cdr-csv/Master.csv
DB=asteriskcdrdb; JSON=0; QUIET=0; MAIL_TO=""; WEBHOOK=""; STATE_DIR=/var/lib/voip-toll-fraud-watch; NOTIFY_CHANGE=1
read -ra MA <<<"${MYSQL_ARGS:-}"

usage() {
  cat <<'EOF'
Usage: voip-toll-fraud-watch.sh [options]
  --minutes N            look at calls that started in the last N minutes (default 60)
  --max-intl-calls N     alarm when more than N international/premium calls were made (default 5)
  --max-intl-minutes N   alarm when international talk time is above N minutes (default 30)
  --max-ext-calls N      warn when one source made more than N outbound calls (default 40)
  --hours HH-HH          office hours, e.g. 08-18; outbound calls outside them are warnings (default: off)
  --days D-D             office days 1-7 (Mon=1) for --hours (default 1-5)
  --intl-regex RE        what counts as international (default '^(\+|00|011|9011|900|9900)')
  --allow-regex RE       destinations that are never suspicious (e.g. your own office abroad: '^01144207946')
  --source db|csv|auto   CDR source (default auto: the FreePBX database if reachable, else Master.csv)
  --db NAME              CDR database (default asteriskcdrdb)     --csv FILE  cdr-csv file
  --json                 JSON output            -q, --quiet   print only when something is suspicious
  --mail ADDR            e-mail alarms (needs 'mail')            --webhook URL  POST JSON alarms
  --every-time           send mail/webhook on every suspicious run (default: only when the findings change)
  -h, --help             this help
If it fires: disable the route or trunk (FreePBX: Connectivity > Outbound Routes), change the secret of the source
extension, check 'pjsip show contacts' for unknown IPs, and ask your SIP provider to block international calls.
Environment: MYSQL_ARGS extra mysql client arguments (FreePBX root works without any)
EOF
}
die() { echo "UNKNOWN: $*" >&2; exit 3; }
num() { [[ "$2" =~ ^[0-9]+$ ]] || die "$1 needs a whole number"; }
while [ $# -gt 0 ]; do
  case "$1" in
    --minutes) num "$1" "${2:-}"; MIN=$2; shift 2 ;;
    --max-intl-calls) num "$1" "${2:-}"; MAX_INTL=$2; shift 2 ;;
    --max-intl-minutes) num "$1" "${2:-}"; MAX_INTL_MIN=$2; shift 2 ;;
    --max-ext-calls) num "$1" "${2:-}"; MAX_EXT=$2; shift 2 ;;
    --hours) [[ "${2:-}" =~ ^([01][0-9]|2[0-3])-([01][0-9]|2[0-4])$ ]] || die "--hours needs HH-HH, e.g. 08-18"; HOURS=$2; shift 2 ;;
    --days) [[ "${2:-}" =~ ^[1-7]-[1-7]$ ]] || die "--days needs D-D, e.g. 1-5"; DAYS=$2; shift 2 ;;
    --intl-regex) INTL_RE="${2:-}"; shift 2 ;;
    --allow-regex) ALLOW_RE="${2:-}"; shift 2 ;;
    --source) SOURCE="${2:-}"; [[ "$SOURCE" =~ ^(db|csv|auto)$ ]] || die "--source is db, csv or auto"; shift 2 ;;
    --db) DB="${2:-}"; [[ "$DB" =~ ^[A-Za-z0-9_]+$ ]] || die "--db: invalid name"; shift 2 ;;
    --csv) CSV="${2:-}"; shift 2 ;;
    --json) JSON=1; shift ;;
    -q|--quiet) QUIET=1; shift ;;
    --mail) MAIL_TO="${2:-}"; [[ "$MAIL_TO" == *@* ]] || die "--mail needs an address"; shift 2 ;;
    --webhook) WEBHOOK="${2:-}"; [[ "$WEBHOOK" =~ ^https?:// ]] || die "--webhook needs an http(s) URL"; shift 2 ;;
    --every-time) NOTIFY_CHANGE=0; shift ;;
    -h|--help) usage; exit 0 ;;
    *) usage >&2; die "unknown option: $1" ;;
  esac
done
for re in "$INTL_RE" "$PREMIUM_RE" "${ALLOW_RE:-x}"; do echo | grep -Eq -- "$re" 2>/dev/null; [ $? -le 1 ] || die "invalid regular expression: $re"; done

# ---- collect outbound-looking calls as TSV: start  src  dst  disposition  billsec  channel  dstchannel ----
TMP=$(mktemp -d) || die "mktemp failed"; trap 'rm -rf "$TMP"' EXIT
rows=$TMP/rows
since=$(date -d "-$MIN minutes" '+%F %T')
# On FreePBX the CDR lives in the database: if that database cannot be queried, stop with UNKNOWN (exit 3) instead of
# quietly reading an old or empty Master.csv and reporting "nothing suspicious".
dbok=0; dberr=""
if [ "$SOURCE" != csv ] && command -v mysql >/dev/null 2>&1; then
  if dberr=$(mysql "${MA[@]}" -N -B "$DB" -e "SELECT 1" 2>&1 >/dev/null); then dbok=1; fi
fi
if [ "$dbok" = 0 ] && [ "$SOURCE" != csv ] && { [ "$SOURCE" = db ] || [ -f /etc/freepbx.conf ]; }; then
  die "cannot query the CDR database $DB ($(head -1 <<<"${dberr:-mysql client not installed}" | cut -c1-100)); check MariaDB and MYSQL_ARGS"
fi
if [ "$dbok" = 1 ]; then
  src=db
  mysql "${MA[@]}" -N -B "$DB" -e "SELECT calldate, src, dst, disposition, billsec, channel, dstchannel, WEEKDAY(calldate)+1, HOUR(calldate) FROM cdr
    WHERE calldate >= '$since' AND dstchannel <> '' AND dstchannel NOT LIKE 'Local/%' ORDER BY calldate" > "$rows" 2>/dev/null \
    || die "query on $DB.cdr failed"
elif [ "$SOURCE" != db ] && [ -r "$CSV" ]; then
  src=csv
  command -v gawk >/dev/null 2>&1 || die "reading $CSV needs gawk (apt install gawk / dnf install gawk)"
  # Master.csv: accountcode,src,dst,dcontext,clid,channel,dstchannel,lastapp,lastdata,start,answer,end,duration,billsec,disposition,amaflags,...
  tail -n 200000 "$CSV" | gawk -v since="$since" -v FPAT='([^,]*)|("[^"]*")' 'BEGIN{OFS="\t"} {for(i=1;i<=NF;i++) gsub(/^"|"$/,"",$i)}
    $10>=since && $7!="" && $7 !~ /^Local\// { t=$10; gsub(/[-:]/," ",t); e=mktime(t); print $10,$2,$3,$15,$14,$6,$7,strftime("%u",e),strftime("%H",e)+0 }' > "$rows" \
    || die "cannot parse $CSV"
else
  die "no CDR source: the $DB database is not reachable and $CSV is not readable (use --source/--db/--csv)"
fi

# ---- classify ----
# regexes go through the environment so awk does not reinterpret their backslashes
export INTL_RE PREMIUM_RE ALLOW_RE
read -r intl_calls intl_secs total after < <(awk -F'\t' -v hours="$HOURS" -v days="$DAYS" '
  BEGIN{split(hours,h,"-"); split(days,d,"-"); ire=ENVIRON["INTL_RE"]; pre=ENVIRON["PREMIUM_RE"]; are=ENVIRON["ALLOW_RE"]}
  { dst=$3; gsub(/[^0-9+*#]/,"",dst); total++
    if (are!="" && dst ~ are) next
    if (dst ~ ire || dst ~ pre) { ic++; is+=$5 }
    if (hours!="") { hh=$9+0; wd=$8+0
      if (hh < h[1]+0 || hh >= h[2]+0 || wd < d[1]+0 || wd > d[2]+0) ah++ } }
  END{printf "%d %d %d %d\n", ic, is, total, ah}' "$rows")
intl_min=$(( (intl_secs + 59) / 60 ))

top_dst=$(awk -F'\t' 'BEGIN{ire=ENVIRON["INTL_RE"]; pre=ENVIRON["PREMIUM_RE"]; are=ENVIRON["ALLOW_RE"]
    n=split("20 27 30 31 32 33 34 36 39 40 41 43 44 45 46 47 48 49 51 52 53 54 55 56 57 58 60 61 62 63 64 65 66 81 82 84 86 90 91 92 93 94 95 98",cc," "); for(i=1;i<=n;i++) two[cc[i]]=1}
  {d=$3; gsub(/[^0-9+]/,"",d); if (are!="" && d ~ are) next
   if (d ~ pre) { c["premium " substr(d,1,4)]++; next }
   if (d ~ ire) { p=d; sub(/^(\+|9?011|9?00|9900)/,"",p); k=substr(p,1,1); if (k!="1" && k!="7") k=(substr(p,1,2) in two) ? substr(p,1,2) : substr(p,1,3); c["+" k]++ } }
  END{for(k in c) print c[k], k}' "$rows" | sort -rn | head -6)
top_src=$(awk -F'\t' '{c[$2]++} END{for(k in c) print c[k], k}' "$rows" | sort -rn | head -5)
failed=$(awk -F'\t' 'BEGIN{ire=ENVIRON["INTL_RE"]} {d=$3; gsub(/[^0-9+]/,"",d)} d ~ ire && $4 != "ANSWERED" {n++} END{print n+0}' "$rows")

# Transit: the call came in on a trunk (channel = a trunk) and left on a trunk
trunks=""; transit_note=""
if [ "$src" = db ] && mysql "${MA[@]}" -N -B asterisk -e "SELECT 1" >/dev/null 2>&1; then
  if tl=$(mysql "${MA[@]}" -N -B asterisk -e "SELECT channelid FROM trunks WHERE disabled <> 'on'" 2>/dev/null); then
    trunks=$(sed -E 's#.*@##; s#^[A-Z0-9]+/##; s#[/$].*##' <<<"$tl" | grep -E '^[A-Za-z0-9_.-]+$' | sort -u | paste -sd'|')
  else transit_note="transit check skipped: the FreePBX trunk list could not be read"; fi
else transit_note="transit check skipped: no FreePBX trunk list (plain Asterisk or CSV source)"; fi
transit=0
[ -n "$trunks" ] && transit=$(awk -F'\t' -v t="$trunks" '$6 ~ "^(PJSIP|SIP|IAX2)/(" t ")-" {n++} END{print n+0}' "$rows")

STATUS=0; FIND=()
add() { case "$1" in ALARM) STATUS=2 ;; WARN) [ "$STATUS" -lt 1 ] && STATUS=1 ;; esac; FIND+=("$1 $2"); }
[ "$intl_calls" -gt "$MAX_INTL" ] && add ALARM "$intl_calls international/premium calls in $MIN min (limit $MAX_INTL)"
[ "$intl_min" -gt "$MAX_INTL_MIN" ] && add ALARM "$intl_min international minutes in $MIN min (limit $MAX_INTL_MIN)"
[ "$transit" -gt 0 ] && add ALARM "$transit call(s) came in on a trunk and went straight back out (transit fraud pattern)"
[ "$failed" -gt 10 ] && add WARN "$failed unanswered international attempts (someone testing destinations?)"
[ -n "$HOURS" ] && [ "$after" -gt 0 ] && add WARN "$after outbound call(s) outside office hours $HOURS (days $DAYS)"
while read -r n s; do [ -n "${n:-}" ] && [ "$n" -gt "$MAX_EXT" ] && add WARN "source $s made $n outbound calls (limit $MAX_EXT)"; done <<<"$top_src"

label=(OK WARNING ALARM); host=$(hostname -f 2>/dev/null || hostname)
summary="${label[$STATUS]}: $total outbound call(s) in the last $MIN min, $intl_calls international/premium ($intl_min min), source $src"
js() { local s=${1//\\/\\\\}; s=${s//\"/\\\"}; printf '"%s"' "${s//$'\n'/; }"; }
json() {
  printf '{"host":%s,"status":%s,"window_minutes":%s,"outbound_calls":%s,"intl_calls":%s,"intl_minutes":%s,"after_hours":%s,"transit":%s,"findings":[' \
    "$(js "$host")" "$(js "${label[$STATUS]}")" "$MIN" "$total" "$intl_calls" "$intl_min" "${after:-0}" "$transit"
  local i; for i in "${!FIND[@]}"; do [ "$i" -gt 0 ] && printf ','; js "${FIND[$i]}"; done
  printf '],"top_country_prefixes":%s,"top_sources":%s,"text":%s}\n' "$(js "$top_dst")" "$(js "$top_src")" "$(js "$host $summary")"
}
report() {
  echo "Toll fraud watch on $host, calls since $since ($src)"
  local x; for x in "${FIND[@]}"; do echo "  $x"; done
  if [ "$intl_calls" -gt 0 ]; then echo "  International/premium by country code (calls, code):"; sed 's/^/    /' <<<"$top_dst"; fi
  if [ "$total" -gt 0 ]; then echo "  Top sources (calls, caller):"; sed 's/^/    /' <<<"$top_src"; fi
  [ -n "$transit_note" ] && echo "  note: $transit_note"
  echo "$summary"
}
if [ "$JSON" = 1 ]; then json; elif [ "$QUIET" = 0 ] || [ "$STATUS" -gt 0 ]; then report; fi

# Notifications: per channel; with change detection the state is saved only after that channel delivered, so a
# failed mail or webhook is retried on the next run. Unchanged findings are re-sent after 2 hours.
if [ -n "$MAIL_TO$WEBHOOK" ] && [ "$STATUS" -gt 0 ]; then
  sig=$(printf '%s\n' "${FIND[@]}" | sed -E 's/[0-9]+//g' | sha256sum | cut -c1-16)
  [ "$NOTIFY_CHANGE" = 1 ] && { mkdir -p "$STATE_DIR" 2>/dev/null && chmod 700 "$STATE_DIR" 2>/dev/null; }
  for ch in mail webhook; do
    [ "$ch" = mail ] && [ -z "$MAIL_TO" ] && continue
    [ "$ch" = webhook ] && [ -z "$WEBHOOK" ] && continue
    if [ "$NOTIFY_CHANGE" = 1 ] && [ "$sig" = "$(cat "$STATE_DIR/last.$ch" 2>/dev/null)" ] && [ -n "$(find "$STATE_DIR/last.$ch" -mmin -120 2>/dev/null)" ]; then continue; fi
    ok=1
    if [ "$ch" = mail ]; then
      if command -v mail >/dev/null 2>&1; then report | mail -s "[TOLL FRAUD] ${label[$STATUS]} on $host" "$MAIL_TO" || ok=0
      else echo "voip-toll-fraud-watch: 'mail' command not found" >&2; ok=0; fi
    else json | curl -fsS -m 15 -H 'Content-Type: application/json' --data-binary @- "$WEBHOOK" >/dev/null || ok=0; fi
    if [ "$ok" = 1 ]; then [ "$NOTIFY_CHANGE" = 1 ] && echo "$sig" > "$STATE_DIR/last.$ch" 2>/dev/null
    else echo "voip-toll-fraud-watch: $ch delivery failed; it will be retried on the next run" >&2; fi
  done
fi
exit "$STATUS"
Version 1.0.1 · SHA-256 b687d3da26669e27f4d6972d353f1d7b7c8158575cb9edc7b8316b152450ac89
Download and verify on Linux or macOS
curl -fsSL -o voip-toll-fraud-watch.sh https://scr.srvscripts.com/voip-toll-fraud-watch/voip-toll-fraud-watch.sh && curl -fsSL https://scr.srvscripts.com/voip-toll-fraud-watch/voip-toll-fraud-watch.sh.sha256 | sha256sum -c
Download and verify in Windows PowerShell
Invoke-WebRequest -Uri 'https://scr.srvscripts.com/voip-toll-fraud-watch/voip-toll-fraud-watch.sh' -OutFile 'voip-toll-fraud-watch.sh'; if ((Get-FileHash 'voip-toll-fraud-watch.sh' -Algorithm SHA256).Hash -eq 'B687D3DA26669E27F4D6972D353F1D7B7C8158575CB9EDC7B8316B152450AC89') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }
Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.
Also on GitHub: github.com/srvscripts/scripts

Frequently asked questions

What is VoIP toll fraud?

Someone uses your PBX or SIP account to make expensive calls, usually to premium or international numbers that pay them a share. You are billed for the calls. It typically starts at night or at weekends.

How quickly does this script detect it?

As fast as you schedule it. Run from cron every 10 minutes, an attack is reported within about 10 minutes of the first suspicious calls.

Does it block the calls?

No. It only reads the call records and alerts you. Blocking is left to you or your provider, so a false alarm cannot stop legitimate calls.

Does it work without FreePBX?

Yes. On plain Asterisk it reads the cdr-csv Master.csv file instead of the FreePBX database. GNU awk is required for that.

How do I stop alerts for calls to our own office abroad?

Add --allow-regex with that number prefix, for example --allow-regex ^01144207946. Matching destinations are never counted as suspicious.

Share this script

Changelog

  • 1.0.1 — On FreePBX, or with --source db, an unreadable CDR database is UNKNOWN (exit 3) instead of a fallback to Master.csv. Skipped transit check shown. Alerts retried until delivered. Found in an independent review.
  • 1.0.0 — Initial release

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.