npx skills add mukul975/Anthropic-Cybersecurity-SkillsMITRE ATT&CK
When to Use
- Monitoring owned domains for unauthorized or unexpected certificate issuance by unknown Certificate Authorities
- Discovering subdomains and hidden services through certificates logged in public CT logs
- Detecting phishing infrastructure that uses look-alike domain certificates (typosquatting, homograph attacks)
- Auditing Certificate Authority compliance by verifying all issued certificates appear in CT logs as required by browser policies
- Building continuous certificate monitoring into a security operations pipeline with alerting for new issuances
Do not use for attacking or disrupting Certificate Authorities, for scraping CT logs in violation of rate limits or terms of service, or as the sole method of subdomain enumeration without corroborating results through DNS verification.
Prerequisites
- Python 3.10+ with
requests,cryptography, andpyOpenSSLlibraries installed - Network access to crt.sh (HTTPS) and public CT log servers
- A list of domains to monitor (owned domains, brand variations, typosquat candidates)
- SMTP credentials or webhook URL for alerting on new certificate discoveries
- Basic understanding of X.509 certificate structure and TLS certificate chain validation
Workflow
Step 1: Domain Inventory and Baseline
Build the initial certificate inventory for monitored domains:
- Define monitoring scope: List all owned root domains, registered brand names, and known subsidiaries. Include wildcard patterns (
%.example.com) for comprehensive subdomain coverage. - Query crt.sh for historical certificates: Use the crt.sh JSON API to retrieve all known certificates for each domain. The API endpoint
https://crt.sh/?q=%.example.com&output=jsonreturns certificates matching the wildcard pattern with fields includingissuer_ca_id,issuer_name,common_name,name_value,not_before,not_after, andserial_number. - Build baseline database: Store the initial certificate set in a local SQLite database with columns for certificate ID, domain, issuer, validity dates, SANs (Subject Alternative Names), and first-seen timestamp. This baseline prevents alerting on already-known certificates.
- Identify authorized CAs: From the baseline, extract the set of Certificate Authorities that have legitimately issued certificates for your domains. Any future issuance from a CA not in this set triggers a high-priority alert.
- Map subdomains: Extract all unique subdomains from the
name_valuefield across all certificates to build an initial subdomain inventory.
Step 2: Continuous CT Log Monitoring
Set up ongoing monitoring for new certificate issuances:
- Poll crt.sh periodically: Query the crt.sh API at regular intervals (every 15-60 minutes) for new certificates. Use the
exclude=expiredparameter to focus on currently valid certificates. Compare results against the baseline database to identify new entries. - Parse certificate details: For each new certificate, extract the full SAN list, issuer chain, validity period, key type and size, CT log SCT (Signed Certificate Timestamp) details, and certificate fingerprint (SHA-256).
- Detect precertificates: CT logs include precertificates (poisoned certificates submitted before final issuance). Track these as early warnings since they indicate a certificate is about to be issued but may not yet be active.
- Monitor CT log Signed Tree Heads (STH): For advanced monitoring, query CT log servers directly to fetch the latest STH and verify consistency proofs between consecutive tree heads. An inconsistency indicates log misbehavior (split-view attack).
- Rate limiting awareness: Respect crt.sh rate limits by spacing queries and caching responses. Implement exponential backoff on HTTP 429 responses. For high-volume monitoring, consider querying the crt.sh PostgreSQL interface directly at
crt.sh:5432. - Atom/RSS feed alternative: Subscribe to crt.sh's Atom feed for lighter-weight monitoring:
https://crt.sh/atom?q=%25.example.comprovides real-time notification of new log entries.
Step 3: Subdomain Discovery via CT Data
Extract and validate subdomains found in certificate transparency data:
- Wildcard expansion: Certificates with wildcard SANs (
*.dev.example.com) reveal the existence of subdomains that may not be in DNS zone files. Record the parent domain as a target for further enumeration. - Historical certificate mining: Query crt.sh without the
exclude=expiredparameter to find subdomains from expired certificates that may still resolve in DNS. These represent historical infrastructure that could be vulnerable to subdomain takeover. - DNS validation: For each discovered subdomain, perform DNS resolution (A, AAAA, CNAME records) to determine if the subdomain is currently active. Cross-reference with known IP ranges to identify shadow IT or unauthorized services.
- Typosquat detection: Generate permutations of the monitored domain (bitsquatting, homograph, insertion, omission, transposition, keyboard-adjacent replacement) and query CT logs for certificates issued to these variations. Certificates for typosquat domains strongly indicate phishing infrastructure.
- Deduplication and enrichment: Normalize discovered subdomains (lowercase, remove trailing dots), deduplicate, and enrich with WHOIS data, IP geolocation, and HTTP response headers to prioritize investigation.
Step 4: Certificate Issuance Alerting
Configure alerting rules for security-relevant certificate events:
- Unauthorized CA alert: Trigger when a certificate is issued by a CA not in the authorized CA list. This is the highest-priority alert as it may indicate domain hijacking, BGP hijacking for domain validation, or a compromised CA.
- New subdomain alert: Trigger when a certificate contains a SAN with a previously unseen subdomain. This catches shadow IT deployments and unauthorized services.
- Wildcard certificate alert: Trigger on any new wildcard certificate issuance, as wildcard certificates have broader impact if compromised and their issuance should be tightly controlled.
- Short-lived certificate anomaly: Alert when certificates have unusually short validity periods (under 24 hours) that deviate from the organization's normal certificate lifecycle, as this may indicate Let's Encrypt abuse or automated phishing infrastructure.
- Expiration warning: Alert when certificates for critical services approach expiration (30, 14, 7 days) based on the
not_afterfield from CT log data. - Alert delivery: Send alerts via email (SMTP), Slack webhook, PagerDuty, or write to a SIEM-compatible JSON log format for integration with existing security monitoring.
Step 5: CT Log Integrity Verification and Reporting
Verify log integrity and produce compliance evidence:
- Signed Tree Head (STH) monitoring: Fetch the latest STH from each monitored CT log via the
get-sthAPI endpoint. The STH contains the tree size and a signed timestamp. Verify the signature using the log's public key. - Consistency proof verification: Between consecutive STH fetches, request a consistency proof via
get-sth-consistencyto verify the log remains append-only and no entries have been modified or removed. - Certificate inventory report: Produce a complete inventory of all certificates issued for monitored domains, grouped by issuer, with validity status and key strength metrics.
- CA diversity analysis: Report on how many different CAs issue certificates for the organization, identifying consolidation opportunities and single-points-of-failure.
- Compliance evidence: For organizations subject to PCI-DSS, SOC 2, or similar frameworks, CT monitoring logs provide evidence of certificate lifecycle management and unauthorized issuance detection capabilities.
Key Concepts
| Term | Definition |
|---|---|
| Certificate Transparency (CT) | An open framework (RFC 6962) requiring Certificate Authorities to log all issued certificates in publicly auditable append-only logs, enabling domain owners to detect unauthorized issuance |
| Signed Certificate Timestamp (SCT) | A promise from a CT log that a certificate will be included within the Maximum Merge Delay (typically 24 hours); browsers require SCTs from multiple logs before trusting a certificate |
| Merkle Tree | The cryptographic data structure used by CT logs where leaf nodes are certificate hashes and parent nodes are hashes of their children, enabling efficient consistency and inclusion proofs |
| Precertificate | A certificate submitted to CT logs before final issuance, containing a poison extension (OID 1.3.6.1.4.1.11129.2.4.3) that prevents it from being used for TLS but reserves its place in the log |
| crt.sh | A free web service operated by Sectigo that aggregates certificates from all major CT logs into a searchable PostgreSQL database, providing both web and API access |
| Subdomain Takeover | A vulnerability where a subdomain's DNS record points to a decommissioned service (cloud provider, CDN) that an attacker can reclaim, made discoverable through expired CT certificates |
| Maximum Merge Delay (MMD) | The maximum time (typically 24 hours) a CT log has to incorporate a submitted certificate into its Merkle tree after returning an SCT |
| CAA Record | DNS Certification Authority Authorization record that specifies which CAs are permitted to issue certificates for a domain; CT monitoring detects violations of CAA policy |
Tools & Systems
- crt.sh: Primary CT log aggregator providing JSON API access at
https://crt.sh/?q=<query>&output=jsonwith support for wildcard queries, identity filtering, and certificate detail retrieval - ct-woodpecker: Open-source CT log monitoring tool from Let's Encrypt that integrates with Prometheus and Grafana for operational monitoring of log health and consistency
- certspotter: SSLMate's CT log monitor that watches for newly issued certificates and sends notifications; available as hosted service or self-hosted tool
- Google Argon / Xenon / Icarus: Google-operated CT logs that are among the most widely used, queryable via the RFC 6962 API at their respective log URLs
- OpenSSL: Command-line tool for parsing certificate details, verifying chains, and extracting SAN lists from certificates retrieved through CT monitoring
Common Scenarios
Scenario: Detecting Unauthorized Certificate Issuance for a Financial Services Company
Context: A bank monitors its primary domain (bank.example.com) and discovers via CT logs that a certificate has been issued by a CA they have never used, covering secure-login.bank.example.com -- a subdomain that does not exist in their DNS.
Approach:
- CT monitoring agent detects a new certificate from "FreeSSL CA" for
secure-login.bank.example.comin crt.sh results, which is not in the authorized CA list (DigiCert, Sectigo) - Alert fires as unauthorized CA + new subdomain, escalating to the security team within 15 minutes of CT log entry
- Investigate the certificate: extract the public key, check if the domain validated via HTTP-01 or DNS-01 challenge, query WHOIS for the issuing organization
- DNS lookup for
secure-login.bank.example.comreveals it resolves to an IP address in a hosting provider not used by the bank -- confirming this is attacker infrastructure - Initiate incident response: request certificate revocation from FreeSSL CA, file a domain abuse report, add the IP to blocklists, and notify the anti-phishing team
- Implement CAA DNS records (
bank.example.com. CAA 0 issue "digicert.com") to prevent unauthorized CAs from issuing future certificates
Pitfalls:
- Not monitoring wildcard patterns (
%.bank.example.com) and missing certificates for subdomains - Ignoring precertificates that appear in CT logs before the actual certificate is issued, losing the early warning advantage
- Failing to verify that CAA records are properly configured on all domains after an incident
- Over-alerting on legitimate certificate renewals because the baseline database was not updated after authorized changes
Scenario: Attack Surface Mapping Through CT Log Subdomain Discovery
Context: A penetration tester uses CT logs as the first phase of external reconnaissance to map the target organization's internet-facing services before active scanning.
Approach:
- Query crt.sh for
%.target.comand all known subsidiary domains, collecting 2,400 unique certificates spanning 8 years - Extract 347 unique subdomains from SAN fields across all certificates, including expired ones
- DNS-resolve all 347 subdomains, finding 189 currently active with A/AAAA records
- Identify 12 subdomains pointing to decommissioned cloud services (CNAME to S3 buckets, Azure endpoints) that are candidates for subdomain takeover
- Discover
staging-api.target.comanddev-portal.target.comwhich are not in the target's documented scope but are reachable and running older software versions - Present findings to the target organization showing the gap between their known asset inventory and the CT-derived attack surface
Pitfalls:
- Assuming all CT-discovered subdomains are in scope without confirming with the asset owner
- Not checking for wildcard DNS responses that make it appear subdomains exist when they resolve to a catch-all
- Relying solely on CT data without cross-referencing with passive DNS databases for comprehensive coverage
Output Format
## CT Log Monitoring Report
**Domain**: example.com
**Monitoring Period**: 2026-03-01 to 2026-03-19
**Total Certificates Tracked**: 142
**New Certificates Detected**: 7
**Alerts Generated**: 2
### Alert: Unauthorized CA Issuance
- **Severity**: Critical
- **Certificate CN**: secure-login.example.com
- **SANs**: secure-login.example.com, www.secure-login.example.com
- **Issuer**: Unknown Free CA (NOT in authorized CA list)
- **Serial**: 04:A3:B7:2F:...:9E
- **Not Before**: 2026-03-18T00:00:00Z
- **Not After**: 2026-06-16T00:00:00Z
- **CT Log**: Google Argon 2026
- **SCT Timestamp**: 2026-03-17T22:15:33Z
- **Action Required**: Investigate immediately, request revocation
### Subdomain Discovery Summary
- **Total Unique Subdomains**: 89
- **New Subdomains This Period**: 3
- api-v3.example.com (DigiCert, valid)
- staging-new.example.com (Let's Encrypt, valid)
- old-portal.example.com (expired 2025-12-01, CNAME to Azure -- takeover risk)
### Typosquatting Alerts
| Domain | Certificate Count | Issuer | Action Required |
|--------|-------------------|--------|-----------------|
| exarnple.com | 2 | Let's Encrypt | Investigate phishing |
| examp1e.com | 1 | ZeroSSL | Investigate phishing |References and resources
Everything below is rendered for inspection. Script files are read-only and never run.
References 1
api-reference.md6.4 KB
API Reference: CT Log Monitoring Agent
Overview
Monitors Certificate Transparency logs via the crt.sh API to detect unauthorized certificate issuance, discover subdomains, detect typosquat phishing infrastructure, and alert security teams. Stores state in SQLite for baseline comparison across monitoring cycles.
Dependencies
| Package | Version | Purpose |
|---|---|---|
| requests | >=2.28 | HTTP requests to crt.sh API and webhook delivery |
| cryptography | >=41.0 | Certificate parsing and validation (optional advanced features) |
| pyOpenSSL | >=23.0 | X.509 certificate chain inspection (optional advanced features) |
The core monitoring functionality requires only requests. The cryptography and pyOpenSSL packages are needed for direct certificate parsing beyond what the crt.sh JSON API provides.
CLI Usage
# One-shot scan with report
python agent.py --domains example.com --db ct_monitor.db --report report.json
# Continuous monitoring with Slack alerts
python agent.py --domains example.com --continuous --interval 900 \
--webhook https://hooks.slack.com/services/XXX/YYY/ZZZ
# Build baseline and auto-detect authorized CAs
python agent.py --domains example.com --auto-baseline --db ct_monitor.db
# Monitor multiple domains with email alerts
python agent.py --domains example.com bank.example.com \
--continuous --interval 600 \
--smtp-host smtp.gmail.com --smtp-port 587 \
--smtp-user alerts@example.com --smtp-pass "app-password" \
--email-to security@example.com soc@example.com
# Scan for typosquat phishing domains
python agent.py --domains example.com --typosquats --report typosquat_report.json
# Manually add an authorized CA
python agent.py --domains example.com --add-ca "DigiCert SHA2 Extended Validation Server CA" 1397Arguments
| Argument | Required | Description |
|---|---|---|
--domains |
Yes | Space-separated list of domains to monitor |
--db |
No | SQLite database path (default: ct_monitor.db) |
--report |
No | Output JSON report to specified path |
--timeout |
No | HTTP request timeout in seconds (default: 30) |
--continuous |
No | Run continuous monitoring loop |
--interval |
No | Monitoring interval in seconds (default: 900) |
--resolve-dns |
No | Resolve discovered subdomains via DNS (default: true) |
--no-resolve-dns |
No | Disable DNS resolution of subdomains |
--typosquats |
No | Enable typosquat domain scanning (slow, rate-limited) |
--webhook |
No | Webhook URL for alert notifications (Slack, Teams) |
--auto-baseline |
No | Auto-populate authorized CAs from current certificates |
--add-ca |
No | Manually add authorized CA: name and crt.sh CA ID |
--smtp-host |
No | SMTP server hostname for email alerts |
--smtp-port |
No | SMTP port (default: 587) |
--smtp-user |
No | SMTP authentication username |
--smtp-pass |
No | SMTP authentication password |
--email-from |
No | Alert email sender address |
--email-to |
No | Alert email recipient address(es) |
-v, --verbose |
No | Enable debug logging |
Key Functions
query_crtsh(domain, exclude_expired, timeout)
Queries the crt.sh JSON API with wildcard domain patterns. Implements retry with exponential backoff on rate limiting (HTTP 429). Returns list of certificate records.
store_certificates(conn, certs, monitored_domain)
Stores certificates in SQLite, deduplicating by crt.sh ID. Returns only newly discovered certificates for alerting.
discover_subdomains(conn, certs, parent_domain)
Extracts unique subdomains from certificate SAN/name_value fields. Handles wildcard entries by recording the parent domain.
resolve_subdomain(subdomain, timeout)
Performs DNS A/AAAA and CNAME resolution for a single subdomain with configurable timeout.
check_unauthorized_ca(conn, new_certs)
Compares certificate issuers against the authorized CA list. Generates critical alerts for unknown CAs.
check_new_subdomain_alerts(conn, new_subdomains, parent_domain)
Generates medium-severity alerts for previously unseen subdomains discovered in CT data.
check_wildcard_certs(conn, new_certs)
Alerts on new wildcard certificate issuances which have broader security impact.
check_short_lived_certs(conn, new_certs, threshold_hours)
Detects certificates with unusually short validity periods that may indicate automated phishing infrastructure.
check_expiring_certs(conn, domain, days_warning)
Checks for certificates approaching expiration at configurable warning thresholds (30, 14, 7 days).
generate_typosquat_candidates(domain)
Generates domain permutations using omission, transposition, keyboard-adjacent replacement, and bitsquatting techniques.
scan_typosquats(domain, timeout)
Queries CT logs for certificates issued to typosquat variations of the monitored domain.
send_email_alert(alerts, smtp_host, ...)
Delivers alert notifications via SMTP with both plaintext and HTML formatting.
send_webhook_alert(alerts, webhook_url, timeout)
Posts alert notifications to a webhook endpoint (Slack, Teams, generic).
generate_report(conn, domain, output_path)
Produces a comprehensive JSON report including certificate inventory, issuer breakdown, subdomain list, and recent alerts.
run_monitor_cycle(conn, domains, ...)
Executes a complete monitoring cycle: query crt.sh, store certificates, discover subdomains, run alert checks, and deliver notifications.
Database Schema
| Table | Purpose |
|---|---|
certificates |
All certificates seen in CT logs with issuer, validity, and SAN data |
subdomains |
Unique subdomains discovered from certificate name_value fields |
authorized_cas |
Whitelist of authorized Certificate Authorities for alert comparison |
alerts |
Generated alerts with type, severity, and acknowledgment status |
Alert Types
| Alert Type | Severity | Trigger |
|---|---|---|
unauthorized_ca |
Critical | Certificate issued by CA not in authorized list |
new_subdomain |
Medium | Previously unseen subdomain in CT data |
wildcard_certificate |
High | New wildcard certificate issuance |
short_lived_certificate |
High | Certificate validity under threshold (default 24h) |
certificate_expiring |
Medium/High | Certificate approaching expiration |
typosquat_detected |
High | CT certificate found for typosquat domain variation |
Scripts 1
agent.py38.3 KB
#!/usr/bin/env python3
"""CT Log Monitoring Agent - Monitors Certificate Transparency logs for unauthorized
certificate issuance, subdomain discovery, and certificate alerting.
For authorized security monitoring and defensive operations only.
"""
import argparse
import hashlib
import json
import logging
import re
import smtplib
import socket
import sqlite3
import sys
import time
from datetime import datetime, timedelta, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path
from urllib.parse import quote_plus, urljoin
import requests
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
CRTSH_BASE = "https://crt.sh"
CRTSH_JSON = f"{CRTSH_BASE}/?output=json"
DEFAULT_TIMEOUT = 30
MAX_RETRIES = 3
RETRY_BACKOFF = 2
# ---------------------------------------------------------------------------
# Database layer
# ---------------------------------------------------------------------------
def init_database(db_path: str) -> sqlite3.Connection:
"""Initialize SQLite database for certificate tracking."""
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript("""
CREATE TABLE IF NOT EXISTS certificates (
id INTEGER PRIMARY KEY,
crtsh_id INTEGER UNIQUE,
domain TEXT NOT NULL,
common_name TEXT,
name_value TEXT,
issuer_name TEXT,
issuer_ca_id INTEGER,
not_before TEXT,
not_after TEXT,
serial_number TEXT,
fingerprint_sha256 TEXT,
entry_timestamp TEXT,
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
is_precert INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS subdomains (
id INTEGER PRIMARY KEY,
subdomain TEXT UNIQUE NOT NULL,
parent_domain TEXT NOT NULL,
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT,
dns_resolved INTEGER DEFAULT 0,
resolved_ip TEXT,
cname_target TEXT
);
CREATE TABLE IF NOT EXISTS authorized_cas (
id INTEGER PRIMARY KEY,
ca_name TEXT UNIQUE NOT NULL,
issuer_ca_id INTEGER,
added_on TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY,
alert_type TEXT NOT NULL,
severity TEXT NOT NULL,
domain TEXT,
details TEXT,
certificate_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
acknowledged INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_certs_domain ON certificates(domain);
CREATE INDEX IF NOT EXISTS idx_certs_issuer ON certificates(issuer_ca_id);
CREATE INDEX IF NOT EXISTS idx_subs_parent ON subdomains(parent_domain);
CREATE INDEX IF NOT EXISTS idx_alerts_type ON alerts(alert_type);
""")
conn.commit()
return conn
# ---------------------------------------------------------------------------
# crt.sh API interaction
# ---------------------------------------------------------------------------
def query_crtsh(domain: str, exclude_expired: bool = True, timeout: int = DEFAULT_TIMEOUT) -> list[dict]:
"""Query crt.sh JSON API for certificates matching domain pattern.
Args:
domain: Domain pattern, e.g. '%.example.com' for wildcard search.
exclude_expired: If True, exclude expired certificates from results.
timeout: HTTP request timeout in seconds.
Returns:
List of certificate records from crt.sh.
"""
params = {"q": domain, "output": "json"}
if exclude_expired:
params["exclude"] = "expired"
for attempt in range(MAX_RETRIES):
try:
resp = requests.get(
CRTSH_BASE,
params=params,
headers={"User-Agent": "CT-Monitor-Agent/1.0 (security-monitoring)"},
timeout=timeout,
)
if resp.status_code == 429:
wait = RETRY_BACKOFF ** (attempt + 1)
logger.warning("Rate limited by crt.sh, waiting %ds before retry", wait)
time.sleep(wait)
continue
resp.raise_for_status()
data = resp.json()
logger.info("crt.sh returned %d certificates for %s", len(data), domain)
return data
except requests.exceptions.JSONDecodeError:
logger.warning("Empty or invalid JSON response from crt.sh for %s", domain)
return []
except requests.exceptions.RequestException as exc:
wait = RETRY_BACKOFF ** (attempt + 1)
logger.warning("crt.sh query failed (attempt %d/%d): %s", attempt + 1, MAX_RETRIES, exc)
if attempt < MAX_RETRIES - 1:
time.sleep(wait)
return []
def get_certificate_detail(crtsh_id: int, timeout: int = DEFAULT_TIMEOUT) -> dict | None:
"""Fetch detailed certificate information from crt.sh by ID."""
try:
resp = requests.get(
f"{CRTSH_BASE}/?d={crtsh_id}",
headers={"User-Agent": "CT-Monitor-Agent/1.0"},
timeout=timeout,
)
resp.raise_for_status()
return {"crtsh_id": crtsh_id, "pem": resp.text}
except requests.exceptions.RequestException as exc:
logger.warning("Failed to fetch certificate %d: %s", crtsh_id, exc)
return None
# ---------------------------------------------------------------------------
# Certificate processing
# ---------------------------------------------------------------------------
def extract_subdomains_from_names(name_value: str) -> list[str]:
"""Extract individual subdomain entries from a crt.sh name_value field.
The name_value field can contain multiple DNS names separated by newlines.
"""
if not name_value:
return []
names = []
for line in name_value.strip().split("\n"):
name = line.strip().lower().rstrip(".")
if name and "*" not in name:
names.append(name)
elif name and name.startswith("*."):
# Record the wildcard parent
names.append(name[2:])
return list(set(names))
def store_certificates(conn: sqlite3.Connection, certs: list[dict], monitored_domain: str) -> list[dict]:
"""Store certificates in database, return list of newly discovered ones."""
new_certs = []
cursor = conn.cursor()
for cert in certs:
crtsh_id = cert.get("id")
if not crtsh_id:
continue
cursor.execute("SELECT 1 FROM certificates WHERE crtsh_id = ?", (crtsh_id,))
if cursor.fetchone():
continue
name_value = cert.get("name_value", "")
issuer_name = cert.get("issuer_name", "")
entry_ts = cert.get("entry_timestamp", "")
not_before = cert.get("not_before", "")
not_after = cert.get("not_after", "")
common_name = cert.get("common_name", "")
serial = cert.get("serial_number", "")
issuer_ca_id = cert.get("issuer_ca_id")
is_precert = 1 if (entry_ts and "precert" in entry_ts.lower()) else 0
cursor.execute(
"""INSERT OR IGNORE INTO certificates
(crtsh_id, domain, common_name, name_value, issuer_name,
issuer_ca_id, not_before, not_after, serial_number,
entry_timestamp, is_precert)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(crtsh_id, monitored_domain, common_name, name_value,
issuer_name, issuer_ca_id, not_before, not_after, serial,
entry_ts, is_precert),
)
new_certs.append(cert)
conn.commit()
return new_certs
def discover_subdomains(conn: sqlite3.Connection, certs: list[dict], parent_domain: str) -> list[str]:
"""Extract and store unique subdomains from certificate name_value fields."""
new_subdomains = []
cursor = conn.cursor()
now = datetime.now(timezone.utc).isoformat()
for cert in certs:
names = extract_subdomains_from_names(cert.get("name_value", ""))
for name in names:
if not name.endswith(parent_domain):
continue
cursor.execute("SELECT 1 FROM subdomains WHERE subdomain = ?", (name,))
if cursor.fetchone():
cursor.execute(
"UPDATE subdomains SET last_seen = ? WHERE subdomain = ?",
(now, name),
)
else:
cursor.execute(
"""INSERT INTO subdomains (subdomain, parent_domain, first_seen, last_seen)
VALUES (?, ?, ?, ?)""",
(name, parent_domain, now, now),
)
new_subdomains.append(name)
conn.commit()
return new_subdomains
# ---------------------------------------------------------------------------
# DNS resolution
# ---------------------------------------------------------------------------
def resolve_subdomain(subdomain: str, timeout: float = 5.0) -> dict:
"""Resolve a subdomain to IP addresses and CNAME targets."""
result = {"subdomain": subdomain, "resolved": False, "ips": [], "cname": None}
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(timeout)
try:
# Check CNAME first
try:
import dns.resolver
answers = dns.resolver.resolve(subdomain, "CNAME")
for rdata in answers:
result["cname"] = str(rdata.target).rstrip(".")
except Exception:
pass
# A record resolution
ips = socket.getaddrinfo(subdomain, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
seen = set()
for family, _type, _proto, _canonname, sockaddr in ips:
ip = sockaddr[0]
if ip not in seen:
result["ips"].append(ip)
seen.add(ip)
result["resolved"] = len(result["ips"]) > 0
except socket.gaierror:
pass
except Exception as exc:
logger.debug("DNS resolution failed for %s: %s", subdomain, exc)
finally:
socket.setdefaulttimeout(old_timeout)
return result
def resolve_all_subdomains(conn: sqlite3.Connection, parent_domain: str) -> list[dict]:
"""Resolve all unresolved subdomains for a parent domain."""
cursor = conn.cursor()
cursor.execute(
"SELECT subdomain FROM subdomains WHERE parent_domain = ? AND dns_resolved = 0",
(parent_domain,),
)
rows = cursor.fetchall()
results = []
for (subdomain,) in rows:
dns_result = resolve_subdomain(subdomain)
results.append(dns_result)
cursor.execute(
"""UPDATE subdomains SET dns_resolved = 1, resolved_ip = ?, cname_target = ?
WHERE subdomain = ?""",
(
",".join(dns_result["ips"]) if dns_result["ips"] else None,
dns_result["cname"],
subdomain,
),
)
conn.commit()
logger.info("Resolved %d subdomains for %s", len(results), parent_domain)
return results
# ---------------------------------------------------------------------------
# Alerting engine
# ---------------------------------------------------------------------------
def check_unauthorized_ca(conn: sqlite3.Connection, new_certs: list[dict]) -> list[dict]:
"""Check if any new certificates were issued by unauthorized CAs."""
cursor = conn.cursor()
cursor.execute("SELECT ca_name, issuer_ca_id FROM authorized_cas")
authorized = {row[1]: row[0] for row in cursor.fetchall()}
if not authorized:
logger.info("No authorized CAs configured; skipping CA validation")
return []
alerts = []
for cert in new_certs:
ca_id = cert.get("issuer_ca_id")
if ca_id and ca_id not in authorized:
alert = {
"alert_type": "unauthorized_ca",
"severity": "critical",
"domain": cert.get("common_name", ""),
"details": json.dumps({
"issuer": cert.get("issuer_name", ""),
"issuer_ca_id": ca_id,
"common_name": cert.get("common_name", ""),
"name_value": cert.get("name_value", ""),
"not_before": cert.get("not_before", ""),
"not_after": cert.get("not_after", ""),
"crtsh_id": cert.get("id"),
}),
"certificate_id": cert.get("id"),
}
cursor.execute(
"""INSERT INTO alerts (alert_type, severity, domain, details, certificate_id)
VALUES (?, ?, ?, ?, ?)""",
(alert["alert_type"], alert["severity"], alert["domain"],
alert["details"], alert["certificate_id"]),
)
alerts.append(alert)
logger.warning(
"ALERT: Unauthorized CA '%s' issued cert for %s",
cert.get("issuer_name"), cert.get("common_name"),
)
conn.commit()
return alerts
def check_new_subdomain_alerts(conn: sqlite3.Connection, new_subdomains: list[str], parent_domain: str) -> list[dict]:
"""Generate alerts for newly discovered subdomains."""
alerts = []
cursor = conn.cursor()
for sub in new_subdomains:
alert = {
"alert_type": "new_subdomain",
"severity": "medium",
"domain": sub,
"details": json.dumps({
"subdomain": sub,
"parent_domain": parent_domain,
"discovered_via": "certificate_transparency",
}),
}
cursor.execute(
"""INSERT INTO alerts (alert_type, severity, domain, details)
VALUES (?, ?, ?, ?)""",
(alert["alert_type"], alert["severity"], alert["domain"], alert["details"]),
)
alerts.append(alert)
logger.info("ALERT: New subdomain discovered: %s", sub)
conn.commit()
return alerts
def check_wildcard_certs(conn: sqlite3.Connection, new_certs: list[dict]) -> list[dict]:
"""Alert on new wildcard certificate issuances."""
alerts = []
cursor = conn.cursor()
for cert in new_certs:
cn = cert.get("common_name", "")
nv = cert.get("name_value", "")
if cn.startswith("*.") or (nv and "*." in nv):
alert = {
"alert_type": "wildcard_certificate",
"severity": "high",
"domain": cn,
"details": json.dumps({
"common_name": cn,
"issuer": cert.get("issuer_name", ""),
"not_before": cert.get("not_before", ""),
"not_after": cert.get("not_after", ""),
"crtsh_id": cert.get("id"),
}),
"certificate_id": cert.get("id"),
}
cursor.execute(
"""INSERT INTO alerts (alert_type, severity, domain, details, certificate_id)
VALUES (?, ?, ?, ?, ?)""",
(alert["alert_type"], alert["severity"], alert["domain"],
alert["details"], alert["certificate_id"]),
)
alerts.append(alert)
logger.warning("ALERT: Wildcard certificate issued for %s", cn)
conn.commit()
return alerts
def check_short_lived_certs(conn: sqlite3.Connection, new_certs: list[dict], threshold_hours: int = 24) -> list[dict]:
"""Alert on certificates with unusually short validity periods."""
alerts = []
cursor = conn.cursor()
for cert in new_certs:
not_before = cert.get("not_before", "")
not_after = cert.get("not_after", "")
if not not_before or not not_after:
continue
try:
nb = datetime.fromisoformat(not_before.replace("T", " ").split(".")[0])
na = datetime.fromisoformat(not_after.replace("T", " ").split(".")[0])
validity_hours = (na - nb).total_seconds() / 3600
if validity_hours < threshold_hours:
alert = {
"alert_type": "short_lived_certificate",
"severity": "high",
"domain": cert.get("common_name", ""),
"details": json.dumps({
"common_name": cert.get("common_name", ""),
"validity_hours": round(validity_hours, 2),
"not_before": not_before,
"not_after": not_after,
"issuer": cert.get("issuer_name", ""),
"crtsh_id": cert.get("id"),
}),
"certificate_id": cert.get("id"),
}
cursor.execute(
"""INSERT INTO alerts (alert_type, severity, domain, details, certificate_id)
VALUES (?, ?, ?, ?, ?)""",
(alert["alert_type"], alert["severity"], alert["domain"],
alert["details"], alert["certificate_id"]),
)
alerts.append(alert)
logger.warning(
"ALERT: Short-lived cert (%dh) for %s",
int(validity_hours), cert.get("common_name"),
)
except (ValueError, TypeError):
continue
conn.commit()
return alerts
def check_expiring_certs(conn: sqlite3.Connection, domain: str, days_warning: list[int] = None) -> list[dict]:
"""Check for certificates approaching expiration."""
if days_warning is None:
days_warning = [30, 14, 7]
alerts = []
cursor = conn.cursor()
now = datetime.now(timezone.utc)
for days in days_warning:
threshold = (now + timedelta(days=days)).isoformat()
cursor.execute(
"""SELECT crtsh_id, common_name, not_after, issuer_name
FROM certificates
WHERE domain = ? AND not_after <= ? AND not_after > ?""",
(domain, threshold, now.isoformat()),
)
for row in cursor.fetchall():
crtsh_id, cn, not_after, issuer = row
alert = {
"alert_type": "certificate_expiring",
"severity": "medium" if days > 7 else "high",
"domain": cn,
"details": json.dumps({
"common_name": cn,
"not_after": not_after,
"days_until_expiry": days,
"issuer": issuer,
"crtsh_id": crtsh_id,
}),
"certificate_id": crtsh_id,
}
alerts.append(alert)
return alerts
# ---------------------------------------------------------------------------
# Typosquat detection
# ---------------------------------------------------------------------------
def generate_typosquat_candidates(domain: str) -> list[str]:
"""Generate domain permutations for typosquat detection.
Implements omission, insertion, transposition, replacement, and
bitsquatting techniques on the second-level domain label.
"""
parts = domain.split(".")
if len(parts) < 2:
return []
label = parts[0]
suffix = ".".join(parts[1:])
candidates = set()
# Omission: remove one character at a time
for i in range(len(label)):
c = label[:i] + label[i + 1:]
if c:
candidates.add(f"{c}.{suffix}")
# Transposition: swap adjacent characters
for i in range(len(label) - 1):
c = list(label)
c[i], c[i + 1] = c[i + 1], c[i]
candidates.add(f"{''.join(c)}.{suffix}")
# Replacement: replace each char with adjacent keyboard keys
keyboard_neighbors = {
"q": "wa", "w": "qeas", "e": "wrds", "r": "etdf", "t": "ryfg",
"y": "tugh", "u": "yijh", "i": "uokj", "o": "iplk", "p": "ol",
"a": "qwsz", "s": "wedxza", "d": "erfcxs", "f": "rtgvcd",
"g": "tyhbvf", "h": "yujnbg", "j": "uikmnh", "k": "ioljm",
"l": "opk", "z": "asx", "x": "zsdc", "c": "xdfv", "v": "cfgb",
"b": "vghn", "n": "bhjm", "m": "njk",
}
for i, ch in enumerate(label):
for neighbor in keyboard_neighbors.get(ch.lower(), ""):
c = label[:i] + neighbor + label[i + 1:]
candidates.add(f"{c}.{suffix}")
# Bitsquatting: flip each bit of each character
for i, ch in enumerate(label):
for bit in range(8):
flipped = chr(ord(ch) ^ (1 << bit))
if flipped.isalnum():
c = label[:i] + flipped + label[i + 1:]
candidates.add(f"{c}.{suffix}")
candidates.discard(domain)
return sorted(candidates)
def scan_typosquats(domain: str, timeout: int = DEFAULT_TIMEOUT) -> list[dict]:
"""Check CT logs for certificates issued to typosquat domains."""
candidates = generate_typosquat_candidates(domain)
logger.info("Generated %d typosquat candidates for %s", len(candidates), domain)
found = []
for candidate in candidates:
certs = query_crtsh(candidate, exclude_expired=True, timeout=timeout)
if certs:
found.append({
"typosquat_domain": candidate,
"original_domain": domain,
"certificate_count": len(certs),
"issuers": list({c.get("issuer_name", "") for c in certs}),
"earliest_cert": min(
(c.get("not_before", "") for c in certs if c.get("not_before")),
default="",
),
})
logger.warning(
"Typosquat found: %s has %d certificates", candidate, len(certs),
)
# Rate-limit to avoid hammering crt.sh
time.sleep(1)
return found
# ---------------------------------------------------------------------------
# Notification delivery
# ---------------------------------------------------------------------------
def send_email_alert(
alerts: list[dict],
smtp_host: str,
smtp_port: int,
smtp_user: str,
smtp_pass: str,
from_addr: str,
to_addrs: list[str],
use_tls: bool = True,
) -> bool:
"""Send alert notifications via email."""
if not alerts:
return True
msg = MIMEMultipart("alternative")
msg["Subject"] = f"CT Monitor Alert: {len(alerts)} new finding(s)"
msg["From"] = from_addr
msg["To"] = ", ".join(to_addrs)
text_body = "Certificate Transparency Monitor - Alert Summary\n"
text_body += "=" * 55 + "\n\n"
for alert in alerts:
text_body += f"Type: {alert['alert_type']}\n"
text_body += f"Severity: {alert['severity']}\n"
text_body += f"Domain: {alert.get('domain', 'N/A')}\n"
details = json.loads(alert.get("details", "{}"))
for k, v in details.items():
text_body += f" {k}: {v}\n"
text_body += "-" * 40 + "\n\n"
html_body = "<html><body>"
html_body += "<h2>Certificate Transparency Monitor - Alert Summary</h2>"
html_body += f"<p><strong>{len(alerts)} alert(s) generated</strong></p>"
for alert in alerts:
severity_color = {
"critical": "#dc3545",
"high": "#fd7e14",
"medium": "#ffc107",
"low": "#28a745",
}.get(alert["severity"], "#6c757d")
html_body += f'<div style="border-left:4px solid {severity_color};padding:10px;margin:10px 0;">'
html_body += f'<strong style="color:{severity_color};">[{alert["severity"].upper()}]</strong> '
html_body += f'{alert["alert_type"]}<br/>'
html_body += f'Domain: {alert.get("domain", "N/A")}<br/>'
details = json.loads(alert.get("details", "{}"))
for k, v in details.items():
html_body += f"<small>{k}: {v}</small><br/>"
html_body += "</div>"
html_body += "</body></html>"
msg.attach(MIMEText(text_body, "plain"))
msg.attach(MIMEText(html_body, "html"))
try:
if use_tls:
server = smtplib.SMTP(smtp_host, smtp_port, timeout=30)
server.starttls()
else:
server = smtplib.SMTP(smtp_host, smtp_port, timeout=30)
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.sendmail(from_addr, to_addrs, msg.as_string())
server.quit()
logger.info("Email alert sent to %s", ", ".join(to_addrs))
return True
except Exception as exc:
logger.error("Failed to send email alert: %s", exc)
return False
def send_webhook_alert(alerts: list[dict], webhook_url: str, timeout: int = DEFAULT_TIMEOUT) -> bool:
"""Send alert notifications to a webhook (Slack, Teams, generic)."""
if not alerts:
return True
payload = {
"text": f"CT Monitor: {len(alerts)} new alert(s)",
"blocks": [],
}
for alert in alerts:
severity_emoji = {
"critical": "[CRITICAL]",
"high": "[HIGH]",
"medium": "[MEDIUM]",
"low": "[LOW]",
}.get(alert["severity"], "[INFO]")
block_text = f"{severity_emoji} *{alert['alert_type']}*\n"
block_text += f"Domain: `{alert.get('domain', 'N/A')}`\n"
details = json.loads(alert.get("details", "{}"))
for k, v in details.items():
block_text += f" {k}: {v}\n"
payload["blocks"].append({"type": "section", "text": {"type": "mrkdwn", "text": block_text}})
try:
resp = requests.post(webhook_url, json=payload, timeout=timeout)
resp.raise_for_status()
logger.info("Webhook alert sent successfully")
return True
except requests.exceptions.RequestException as exc:
logger.error("Failed to send webhook alert: %s", exc)
return False
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def generate_report(conn: sqlite3.Connection, domain: str, output_path: str = None) -> dict:
"""Generate a comprehensive CT monitoring report."""
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM certificates WHERE domain = ?", (domain,))
total_certs = cursor.fetchone()[0]
cursor.execute(
"""SELECT COUNT(*) FROM certificates
WHERE domain = ? AND first_seen >= datetime('now', '-24 hours')""",
(domain,),
)
new_certs_24h = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM subdomains WHERE parent_domain = ?", (domain,))
total_subdomains = cursor.fetchone()[0]
cursor.execute(
"""SELECT COUNT(*) FROM subdomains
WHERE parent_domain = ? AND first_seen >= datetime('now', '-24 hours')""",
(domain,),
)
new_subdomains_24h = cursor.fetchone()[0]
cursor.execute(
"SELECT COUNT(*) FROM alerts WHERE domain LIKE ? AND acknowledged = 0",
(f"%{domain}%",),
)
open_alerts = cursor.fetchone()[0]
# Top issuers
cursor.execute(
"""SELECT issuer_name, COUNT(*) as cnt
FROM certificates WHERE domain = ?
GROUP BY issuer_name ORDER BY cnt DESC LIMIT 10""",
(domain,),
)
top_issuers = [{"issuer": r[0], "count": r[1]} for r in cursor.fetchall()]
# Recent alerts
cursor.execute(
"""SELECT alert_type, severity, domain, details, created_at
FROM alerts WHERE domain LIKE ?
ORDER BY created_at DESC LIMIT 20""",
(f"%{domain}%",),
)
recent_alerts = [
{
"type": r[0], "severity": r[1], "domain": r[2],
"details": json.loads(r[3]) if r[3] else {}, "created_at": r[4],
}
for r in cursor.fetchall()
]
# Subdomains with DNS status
cursor.execute(
"""SELECT subdomain, dns_resolved, resolved_ip, cname_target, first_seen
FROM subdomains WHERE parent_domain = ? ORDER BY first_seen DESC""",
(domain,),
)
subdomain_list = [
{
"subdomain": r[0], "dns_resolved": bool(r[1]),
"ips": r[2].split(",") if r[2] else [],
"cname": r[3], "first_seen": r[4],
}
for r in cursor.fetchall()
]
report = {
"report_generated": datetime.now(timezone.utc).isoformat(),
"monitored_domain": domain,
"summary": {
"total_certificates": total_certs,
"new_certificates_24h": new_certs_24h,
"total_subdomains": total_subdomains,
"new_subdomains_24h": new_subdomains_24h,
"open_alerts": open_alerts,
},
"top_issuers": top_issuers,
"recent_alerts": recent_alerts,
"subdomains": subdomain_list,
}
if output_path:
with open(output_path, "w") as f:
json.dump(report, f, indent=2)
logger.info("Report saved to %s", output_path)
return report
# ---------------------------------------------------------------------------
# Authorized CA management
# ---------------------------------------------------------------------------
def add_authorized_ca(conn: sqlite3.Connection, ca_name: str, ca_id: int = None):
"""Add a CA to the authorized issuers list."""
conn.execute(
"INSERT OR IGNORE INTO authorized_cas (ca_name, issuer_ca_id) VALUES (?, ?)",
(ca_name, ca_id),
)
conn.commit()
logger.info("Added authorized CA: %s (ID: %s)", ca_name, ca_id)
def auto_populate_authorized_cas(conn: sqlite3.Connection, domain: str):
"""Auto-populate authorized CAs from existing certificate baseline."""
cursor = conn.cursor()
cursor.execute(
"""SELECT DISTINCT issuer_name, issuer_ca_id
FROM certificates WHERE domain = ?""",
(domain,),
)
for issuer_name, issuer_ca_id in cursor.fetchall():
if issuer_name:
add_authorized_ca(conn, issuer_name, issuer_ca_id)
logger.info("Auto-populated authorized CAs from baseline for %s", domain)
# ---------------------------------------------------------------------------
# Main monitoring loop
# ---------------------------------------------------------------------------
def run_monitor_cycle(
conn: sqlite3.Connection,
domains: list[str],
resolve_dns: bool = True,
check_typosquats: bool = False,
webhook_url: str = None,
timeout: int = DEFAULT_TIMEOUT,
) -> dict:
"""Run a single monitoring cycle for all configured domains."""
cycle_results = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"domains_checked": len(domains),
"new_certificates": 0,
"new_subdomains": 0,
"alerts": [],
}
for domain in domains:
root_domain = domain.lstrip("%.")
query_pattern = f"%.{root_domain}"
logger.info("Monitoring domain: %s (query: %s)", root_domain, query_pattern)
# Query crt.sh
certs = query_crtsh(query_pattern, exclude_expired=True, timeout=timeout)
if not certs:
logger.warning("No certificates returned for %s", query_pattern)
continue
# Store and detect new certs
new_certs = store_certificates(conn, certs, root_domain)
cycle_results["new_certificates"] += len(new_certs)
# Subdomain discovery
new_subs = discover_subdomains(conn, certs, root_domain)
cycle_results["new_subdomains"] += len(new_subs)
# DNS resolution
if resolve_dns and new_subs:
resolve_all_subdomains(conn, root_domain)
# Alert checks
ca_alerts = check_unauthorized_ca(conn, new_certs)
sub_alerts = check_new_subdomain_alerts(conn, new_subs, root_domain)
wc_alerts = check_wildcard_certs(conn, new_certs)
sl_alerts = check_short_lived_certs(conn, new_certs)
exp_alerts = check_expiring_certs(conn, root_domain)
all_alerts = ca_alerts + sub_alerts + wc_alerts + sl_alerts + exp_alerts
cycle_results["alerts"].extend(all_alerts)
# Typosquat scanning (expensive, run periodically)
if check_typosquats:
typosquats = scan_typosquats(root_domain, timeout=timeout)
for ts in typosquats:
alert = {
"alert_type": "typosquat_detected",
"severity": "high",
"domain": ts["typosquat_domain"],
"details": json.dumps(ts),
}
all_alerts.append(alert)
cycle_results["alerts"].append(alert)
# Send notifications
if all_alerts and webhook_url:
send_webhook_alert(all_alerts, webhook_url, timeout=timeout)
logger.info(
"Monitoring cycle complete: %d new certs, %d new subdomains, %d alerts",
cycle_results["new_certificates"],
cycle_results["new_subdomains"],
len(cycle_results["alerts"]),
)
return cycle_results
def main():
parser = argparse.ArgumentParser(
description="CT Log Monitoring Agent - Monitor Certificate Transparency logs",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# One-shot scan for a domain
python agent.py --domains example.com --db ct_monitor.db --report report.json
# Continuous monitoring with Slack webhook
python agent.py --domains example.com bank.example.com --continuous --interval 900 \\
--webhook https://hooks.slack.com/services/XXX/YYY/ZZZ
# Scan with typosquat detection
python agent.py --domains example.com --typosquats --report report.json
# Auto-populate authorized CAs from baseline
python agent.py --domains example.com --auto-baseline --db ct_monitor.db
""",
)
parser.add_argument(
"--domains", nargs="+", required=True,
help="Domain(s) to monitor (e.g., example.com bank.example.com)",
)
parser.add_argument("--db", default="ct_monitor.db", help="SQLite database path (default: ct_monitor.db)")
parser.add_argument("--report", help="Output JSON report to this path")
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="HTTP request timeout in seconds")
parser.add_argument("--continuous", action="store_true", help="Run continuous monitoring loop")
parser.add_argument("--interval", type=int, default=900, help="Monitoring interval in seconds (default: 900)")
parser.add_argument("--resolve-dns", action="store_true", default=True, help="Resolve discovered subdomains via DNS")
parser.add_argument("--no-resolve-dns", action="store_false", dest="resolve_dns", help="Disable DNS resolution")
parser.add_argument("--typosquats", action="store_true", help="Enable typosquat domain scanning (slow)")
parser.add_argument("--webhook", help="Webhook URL for alert notifications (Slack, Teams)")
parser.add_argument("--auto-baseline", action="store_true", help="Auto-populate authorized CAs from current certs")
parser.add_argument(
"--add-ca", nargs=2, metavar=("CA_NAME", "CA_ID"),
help="Manually add an authorized CA (name and crt.sh CA ID)",
)
parser.add_argument("--smtp-host", help="SMTP server for email alerts")
parser.add_argument("--smtp-port", type=int, default=587, help="SMTP port (default: 587)")
parser.add_argument("--smtp-user", help="SMTP username")
parser.add_argument("--smtp-pass", help="SMTP password")
parser.add_argument("--email-from", help="Alert email sender address")
parser.add_argument("--email-to", nargs="+", help="Alert email recipient address(es)")
parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging")
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
conn = init_database(args.db)
logger.info("Database initialized: %s", args.db)
# Add authorized CA manually
if args.add_ca:
add_authorized_ca(conn, args.add_ca[0], int(args.add_ca[1]))
return
# Auto-baseline mode
if args.auto_baseline:
for domain in args.domains:
logger.info("Building baseline for %s...", domain)
certs = query_crtsh(f"%.{domain}", exclude_expired=True, timeout=args.timeout)
if certs:
store_certificates(conn, certs, domain)
discover_subdomains(conn, certs, domain)
auto_populate_authorized_cas(conn, domain)
if args.resolve_dns:
resolve_all_subdomains(conn, domain)
logger.info("Baseline complete for %s", domain)
if args.report:
for domain in args.domains:
generate_report(conn, domain, args.report)
conn.close()
return
# Run monitoring
if args.continuous:
logger.info(
"Starting continuous monitoring for %s (interval: %ds)",
", ".join(args.domains), args.interval,
)
try:
while True:
cycle = run_monitor_cycle(
conn, args.domains,
resolve_dns=args.resolve_dns,
check_typosquats=args.typosquats,
webhook_url=args.webhook,
timeout=args.timeout,
)
# Email alerts if configured
if cycle["alerts"] and args.smtp_host and args.email_to:
send_email_alert(
cycle["alerts"],
args.smtp_host, args.smtp_port,
args.smtp_user, args.smtp_pass,
args.email_from or args.smtp_user,
args.email_to,
)
if args.report:
for domain in args.domains:
generate_report(conn, domain, args.report)
logger.info("Sleeping %ds until next cycle...", args.interval)
time.sleep(args.interval)
except KeyboardInterrupt:
logger.info("Monitoring stopped by user")
else:
cycle = run_monitor_cycle(
conn, args.domains,
resolve_dns=args.resolve_dns,
check_typosquats=args.typosquats,
webhook_url=args.webhook,
timeout=args.timeout,
)
if cycle["alerts"] and args.smtp_host and args.email_to:
send_email_alert(
cycle["alerts"],
args.smtp_host, args.smtp_port,
args.smtp_user, args.smtp_pass,
args.email_from or args.smtp_user,
args.email_to,
)
if args.report:
for domain in args.domains:
generate_report(conn, domain, args.report)
conn.close()
logger.info("CT monitoring agent finished")
if __name__ == "__main__":
main()