Skip to content

Repository files navigation

domain-scanner

ci Go 1.24+ License: MIT

A small Go daemon that proactively scans a list of domains for SSL, DNS and HTTP hygiene issues and exposes the results as Prometheus metrics. It is a reference implementation distilled from tooling I have built and operated in production; it is intentionally compact and dependency-light so the whole codebase can be read in one sitting.

What it checks

Area Findings
SSL/TLS Certificate expiry (with configurable warning window), invalid chains, servers still accepting TLS 1.0/1.1
DNS Domains that no longer resolve, dangling CNAMEs (subdomain-takeover risk)
HTTP 5xx from the root path, missing security headers (HSTS, CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy)

Quick start

go build -o domain-scanner .
cp configs/domains.example.yaml configs/domains.yaml   # edit the domain list

# One-shot mode: prints findings, exits 1 if any issue was found.
# Useful in CI or cron.
./domain-scanner -config configs/domains.yaml -once

# Daemon mode: rescans on an interval, serves Prometheus metrics.
./domain-scanner -config configs/domains.yaml -listen :9315
curl -s localhost:9315/metrics | grep domain_scanner

Metrics

All metrics live under the domain_scanner_ namespace:

Metric Type Labels Meaning
cert_expiry_seconds gauge domain Seconds until leaf cert expiry (negative = expired)
cert_valid gauge domain 1 if the chain verifies against system roots
tls_weak_protocol gauge domain 1 if the server accepts TLS < 1.2
dns_resolvable gauge domain 1 if the domain resolves
dns_dangling_cname gauge domain 1 if a CNAME points at a non-existent target
http_status_code gauge domain Status of GET https://<domain>/
http_security_header_missing gauge domain, header 1 per missing recommended header
check_errors_total counter domain, check Failed check executions (timeouts, resolver errors)
scan_duration_seconds, last_scan_timestamp_seconds gauge Scan health

Example alert rules

- alert: CertificateExpiringSoon
  expr: domain_scanner_cert_expiry_seconds < 14 * 86400
  for: 1h
  labels: {severity: warning}

- alert: DanglingCNAME
  expr: domain_scanner_dns_dangling_cname == 1
  labels: {severity: critical}

- alert: ScannerStale
  expr: time() - domain_scanner_last_scan_timestamp_seconds > 3600
  labels: {severity: warning}

Deployment

A hardened systemd unit is provided in deploy/domain-scanner.service:

sudo useradd --system --shell /usr/sbin/nologin domain-scanner
sudo install -m 0755 domain-scanner /usr/local/bin/
sudo install -Dm 0644 configs/domains.yaml /etc/domain-scanner/domains.yaml
sudo install -m 0644 deploy/domain-scanner.service /etc/systemd/system/
sudo systemctl enable --now domain-scanner

Design notes

  • Checks are independent packages under internal/checks; each returns a plain result struct and never touches metrics, so they are easy to test and reuse.
  • DNS runs first: an unresolvable domain short-circuits the TLS/HTTP probes to keep noise out of the metrics.
  • On a chain-verification failure the scanner retries the handshake without verification so it can still report expiry data for the bad certificate.
  • The weak-TLS probe performs a second handshake capped at TLS 1.1; nothing is transmitted over that connection.
  • Bounded concurrency (concurrency in the config) keeps the scanner polite to resolvers and origins.

Development

make test     # unit tests with the race detector
make cover    # coverage report, opens in a browser
make vet lint # go vet, golangci-lint
make scan     # single scan against configs/domains.example.yaml
make docker   # container image

How this is tested

Network checks are the awkward part of a scanner: the interesting behaviour only shows up against endpoints that are broken in specific ways, and no test should depend on some third party's certificate staying expired. So the code is split so the decisions are testable without a network:

  • DNScheckDNS takes a small resolver interface, and the tests feed it canned NXDOMAIN and CNAME answers. The dangling-CNAME logic — the check with real security value — is covered directly, including the case where a CNAME and an NXDOMAIN arrive together.
  • TLS — the handshake runs against a local httptest TLS server. Its certificate is self-signed, which exercises the insecure-retry path: expiry data must still be reported for a certificate that does not verify.
  • HTTP — a local server returns chosen header sets, and the tests assert which headers are reported missing and that the order stays stable, so successive scan outputs diff cleanly.
  • FindingsdnsIssues / sslIssues / httpIssues are pure functions over result structs, so expiry thresholds and message wording are covered without any I/O at all.

Two behaviours are asserted that are easy to regress and annoying to debug in production: a gauge must fall back to 0 when a condition clears (a stale 1 keeps an alert firing after the problem is fixed), and every exported metric must carry a help string and the shared namespace prefix.

CI runs gofmt, go vet, go mod tidy verification, race-enabled tests with a coverage summary, golangci-lint, govulncheck, and a smoke test of the built binary that asserts the documented exit codes (2 for a bad config, 1 when a scan finds issues, 0 when it does not).

Coverage sits at ~86% for internal/checks and 100% for internal/metrics. main is lower by design: the daemon loop and signal handling are left to the smoke test rather than mocked into meaninglessness.

License

MIT — see LICENSE.

About

Go daemon that scans domains for SSL, DNS and HTTP security issues and exposes results as Prometheus metrics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages