
Automate TLS Cipher and Protocol Scanning of Your Own Services
Before you run this
This guide sets up an automated, repeatable scan of the TLS protocols and cipher suites your own services advertise — the sort of thing you'd otherwise do by hand before a PCI review or after a config change. It uses testssl.sh, a widely-used bash script that connects to a service and negotiates handshakes to see what's on offer (TLS 1.0–1.3, weak ciphers, known issues like ROBOT/BEAST reporting, cert details).
A few things to be clear about before you schedule anything:
- It is read-only against the target. It connects and negotiates like a client; it does not change the target's configuration. The only files it writes are the report files on the box you run it from.
- It does not need root.
testssl.shruns fine as an ordinary user. (The one exception in this guide isnmap's raw-socket scan, mentioned at the end, which wants root — but that's optional.) - Only scan systems you are authorised to scan. Point this at your own endpoints. Scanning someone else's host, even lightly, can trip their IDS and is not your call to make. On your own gear, a scan can still generate connection-log noise and briefly load a very old device, so the first run belongs in a maintenance window.
- Test on one host first. Read the wrapper script, run it against a single non-production endpoint (a staging web server, a test VM), and confirm the output looks sane before you turn it loose on a host list and a timer.
Nothing here is destructive and there is nothing to "undo" on the target. The only cleanup is removing the schedule and the report files, which I cover at the end.
What I'm assuming
- Debian 12 or Ubuntu 22.04, bash, and you have
sudofor installing packages and creating a systemd unit. - You're scanning your own HTTPS/SMTP/IMAP/etc. endpoints from a management host that can reach them.
- Commands differ only slightly on RHEL/Alma: swap
aptfordnf, and the package name may not exist in base repos — there I'd clone the script from GitHub instead (shown below).
Install testssl.sh
Debian and Ubuntu ship it as a package:
sudo apt update
sudo apt install testssl.sh
That gives you the testssl.sh command on your PATH. If you want the newest release (the packaged one can lag), clone it instead — it's a self-contained bash script with no build step:
git clone --depth 1 https://github.com/testssl/testssl.sh.git
cd testssl.sh
./testssl.sh --version
If you clone it, call ./testssl.sh by path rather than the packaged command. Adjust the paths in the wrapper below to match.
A single scan by hand
Before automating anything, run one scan and read the output:
# Scan one HTTPS endpoint, plain text, no interactive prompts
testssl.sh --quiet --color 0 --warnings batch example.com:443
--quietdrops the banner,--color 0gives clean text for logs,--warnings batchmeans it won't stop to wait for a keypress — essential for unattended runs.- For a mail submission port you'd use STARTTLS.
testssl.shhas a--starttlsoption that takes a protocol name; runtestssl.sh --helpand check the "STARTTLS" line for the exact keyword your version expects (e.g. smtp, imap) rather than guessing it.
Replace example.com:443 with your own host and port.
The wrapper script
This reads a list of endpoints, scans each one, and writes a timestamped human-readable log plus a JSON file per host. Save it as tls-scan.sh:
#!/usr/bin/env bash
set -euo pipefail
# --- edit these two lines ---
HOSTS_FILE="/etc/tls-scan/hosts.txt" # one "host:port" per line
OUT_DIR="/var/log/tls-scan" # where reports go
# ----------------------------
TESTSSL="testssl.sh" # or /opt/testssl.sh/testssl.sh if cloned
STAMP="$(date +%Y-%m-%d_%H%M)"
RUN_DIR="${OUT_DIR}/${STAMP}"
mkdir -p "$RUN_DIR"
while IFS= read -r target; do
# skip blank lines and comments
[[ -z "$target" || "$target" =~ ^# ]] && continue
safe="${target//:/_}" # host_port for filenames
echo ">> scanning ${target}"
# -oj writes JSON; the text goes to the .log file
"$TESTSSL" --quiet --color 0 --warnings batch \
-oj "${RUN_DIR}/${safe}.json" \
"$target" > "${RUN_DIR}/${safe}.log" 2>&1 || \
echo "!! ${target} returned non-zero (see ${safe}.log)"
done < "$HOSTS_FILE"
echo "Reports in ${RUN_DIR}"
Create the host list — obvious placeholders, replace with your real endpoints, one per line:
sudo mkdir -p /etc/tls-scan
sudo tee /etc/tls-scan/hosts.txt >/dev/null <<'EOF'
# host:port — your own services only
www.example.com:443
mail.example.com:443
EOF
Make it executable and do a first run by hand, watching the output:
chmod +x tls-scan.sh
sudo mkdir -p /var/log/tls-scan
./tls-scan.sh
-ojproduces JSON;-oJproduces pretty-printed JSON, and--severity <LEVEL>limits output to findings at or above a level. The exact accepted severity keywords vary between releases — checktestssl.sh --helpand the project README before you rely on them in a filter.
Flag the problems, not the whole report
A full report is long. For a quick "did anything bad turn up" check, grep the JSON for the higher-severity findings. The JSON field names and shape depend on your testssl.sh version — open one of your own .json files first and confirm the format before trusting this. On current releases each finding carries a severity value, so:
# List HIGH/CRITICAL findings across the latest run
grep -rE '"severity"\s*:\s*"(HIGH|CRITICAL)"' /var/log/tls-scan/ | sort -u
If you have jq installed and your JSON is an array of finding objects, this is cleaner — but verify the structure against your file rather than assuming it:
jq -r 'if type=="array" then .[] else .findings[] end
| select(.severity=="HIGH" or .severity=="CRITICAL")
| "\(.id): \(.finding)"' /var/log/tls-scan/*/www.example.com_443.json
Don't wire this into an alerting pipeline until you've eyeballed the output against the same host scanned by hand — I've seen field names move between versions, and a silent filter that matches nothing is worse than no filter.
Schedule it
I use a systemd timer. Create the service unit at /etc/systemd/system/tls-scan.service:
[Unit]
Description=TLS cipher/protocol scan of our own services
[Service]
Type=oneshot
ExecStart=/usr/local/bin/tls-scan.sh
User=root
Move the script somewhere the unit expects and mark it executable:
sudo install -m 0755 tls-scan.sh /usr/local/bin/tls-scan.sh
Create the timer at /etc/systemd/system/tls-scan.timer:
[Unit]
Description=Weekly TLS scan
[Timer]
OnCalendar=Mon 03:00
Persistent=true
[Install]
WantedBy=timers.target
Enable it:
sudo systemctl daemon-reload
sudo systemctl enable --now tls-scan.timer
If you'd rather use cron, a single root crontab line calling /usr/local/bin/tls-scan.sh does the same job; systemd just gives you Persistent=true (catch-up after downtime) and cleaner logs.
Verify it worked
Confirm the timer is registered and see when it fires next:
systemctl list-timers tls-scan.timer
Run the service once on demand and check it exits cleanly, then confirm reports were written:
sudo systemctl start tls-scan.service
systemctl status tls-scan.service # look for a clean exit
ls -lR /var/log/tls-scan/ # timestamped dir with .log + .json per host
As an independent cross-check, nmap has a well-established script that enumerates ciphers and grades them A–F. It's a good second opinion when a testssl.sh result surprises you (this one wants root for a full scan):
sudo nmap --script ssl-enum-ciphers -p 443 www.example.com
Undo and cleanup
There is nothing to reverse on the scanned hosts — this only ever read from them. To remove the automation from your management box:
sudo systemctl disable --now tls-scan.timer
sudo rm /etc/systemd/system/tls-scan.timer /etc/systemd/system/tls-scan.service
sudo systemctl daemon-reload
sudo rm -f /usr/local/bin/tls-scan.sh
sudo rm -rf /var/log/tls-scan # deletes accumulated reports — keep them if you need history
sudo apt remove testssl.sh # only if you installed the package and want it gone
Keep the reports if you care about a before/after trail across config changes — that history is half the reason to automate this in the first place. For flag meanings and the full option list, the canonical reference is the testssl.sh project README on GitHub and testssl.sh --help.
Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.
More about the author →Was this article helpful?
Tap a star — no sign-in needed.
Be the first to rate this article.
Related guides
Automate a Read-Only Security Audit Report for a Linux Server
This guide builds a single bash script that takes a read-only snapshot of a server's security posture — logged-in users, listening sockets, running services, UID 0 accounts, the effective SSH config, pending updates, SUID/SGID binaries, and world-writable files — and writes it to a timestamped text file. It inspects and records; it never edits config, kills processes, changes permissions, or installs anything.
Build a Service Health Dashboard with Cron and Static HTML
This guide builds a small read-only status page. A bash script checks whether a list of systemd services are active and whether a few HTTP endpoints answer, then writes a plain HTML file that your web server already serves. Cron re-runs it every few minutes so the page stays current. Nothing here restarts, reconfigures, or stops any service — it only reads state and writes one HTML file.
Automate SSL Certificate Expiry Monitoring Across Many Domains
This guide builds a small bash script that connects to a list of hostnames over TLS, reads the expiry date from each certificate, and emails you a summary of anything expiring within a threshold you choose (30 days by default). It is a read-only monitor : it makes outbound TLS connections and sends mail. It does not touch, renew, or modify any certificate, and it changes nothing on the servers it checks.
Replace a Cron Job with a systemd Timer, Logging, and Failure Alerts
This guide replaces a cron job with three small systemd units: a service that runs your task, a timer that schedules it, and a small notification service that emails you when the task fails. The point is to get the two things cron does not give you for free — the task's output captured in the journal, and an alert when it exits non-zero.




