changedetection.io-osint-processor 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- changedetection_io_osint_processor-0.0.1.dist-info/METADATA +274 -0
- changedetection_io_osint_processor-0.0.1.dist-info/RECORD +29 -0
- changedetection_io_osint_processor-0.0.1.dist-info/WHEEL +5 -0
- changedetection_io_osint_processor-0.0.1.dist-info/entry_points.txt +2 -0
- changedetection_io_osint_processor-0.0.1.dist-info/licenses/LICENSE +661 -0
- changedetection_io_osint_processor-0.0.1.dist-info/top_level.txt +1 -0
- changedetectionio_osint/__init__.py +22 -0
- changedetectionio_osint/forms.py +289 -0
- changedetectionio_osint/plugin.py +37 -0
- changedetectionio_osint/processor.py +655 -0
- changedetectionio_osint/steps/__init__.py +4 -0
- changedetectionio_osint/steps/base.py +76 -0
- changedetectionio_osint/steps/bgp.py +88 -0
- changedetectionio_osint/steps/dns.py +147 -0
- changedetectionio_osint/steps/dns_scan.py +88 -0
- changedetectionio_osint/steps/dnssec.py +260 -0
- changedetectionio_osint/steps/email_security.py +236 -0
- changedetectionio_osint/steps/http_fingerprint.py +359 -0
- changedetectionio_osint/steps/http_scan.py +31 -0
- changedetectionio_osint/steps/mac_lookup.py +209 -0
- changedetectionio_osint/steps/os_detection.py +245 -0
- changedetectionio_osint/steps/portscan.py +113 -0
- changedetectionio_osint/steps/registry.py +49 -0
- changedetectionio_osint/steps/smtp_fingerprint.py +517 -0
- changedetectionio_osint/steps/ssh_fingerprint.py +310 -0
- changedetectionio_osint/steps/tls_analysis.py +332 -0
- changedetectionio_osint/steps/traceroute.py +127 -0
- changedetectionio_osint/steps/whois_lookup.py +125 -0
- changedetectionio_osint/steps/whois_scan.py +123 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base class and interface for OSINT scan steps
|
|
3
|
+
|
|
4
|
+
All scan steps should inherit from ScanStep and implement:
|
|
5
|
+
- scan() method: Performs the actual scanning
|
|
6
|
+
- format_results() method: Formats results for output
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScanStep(ABC):
|
|
14
|
+
"""
|
|
15
|
+
Base class for OSINT reconnaissance scan steps.
|
|
16
|
+
|
|
17
|
+
Each step represents an independent scan operation that can be
|
|
18
|
+
run serially or in parallel with other steps.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Step name (used for section header in output)
|
|
22
|
+
# Override in subclass
|
|
23
|
+
name: str = "Unknown Step"
|
|
24
|
+
|
|
25
|
+
# Step order/priority (lower numbers run first in serial mode)
|
|
26
|
+
# Override in subclass
|
|
27
|
+
order: int = 100
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def scan(self, context: dict) -> Any:
|
|
31
|
+
"""
|
|
32
|
+
Perform the scan operation.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
context: Dictionary containing scan context:
|
|
36
|
+
- hostname: Target hostname
|
|
37
|
+
- ip_address: Resolved IP address
|
|
38
|
+
- url: Full target URL
|
|
39
|
+
- parsed_url: Parsed URL object
|
|
40
|
+
- dns_resolver: Configured DNS resolver
|
|
41
|
+
- proxy_url: Optional proxy URL
|
|
42
|
+
- watch_uuid: Watch UUID for status updates
|
|
43
|
+
- update_signal: Blinker signal for status updates
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Scan results (format depends on scan type)
|
|
47
|
+
"""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def format_results(self, results: Any) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Format scan results for output.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
results: Results from scan() method
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Formatted string with section header and content
|
|
60
|
+
"""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
def should_run(self, context: dict) -> bool:
|
|
64
|
+
"""
|
|
65
|
+
Determine if this step should run based on context.
|
|
66
|
+
|
|
67
|
+
Override this method if the step should only run under certain conditions
|
|
68
|
+
(e.g., TLS scan only for HTTPS URLs).
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
context: Scan context dictionary
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True if step should run, False otherwise
|
|
75
|
+
"""
|
|
76
|
+
return True
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BGP Information Step
|
|
3
|
+
Retrieves BGP/ASN information about the target IP
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
# SOCKS5 proxy support: BGP uses HTTP APIs, but proxy not currently implemented
|
|
8
|
+
supports_socks5 = False
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def scan_bgp(ip_address, watch_uuid=None, update_signal=None):
|
|
13
|
+
"""
|
|
14
|
+
Retrieve BGP/ASN information for target IP
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
ip_address: Target IP address
|
|
18
|
+
watch_uuid: Optional watch UUID for status updates
|
|
19
|
+
update_signal: Optional blinker signal for status updates
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
dict: BGP/ASN information
|
|
23
|
+
"""
|
|
24
|
+
if update_signal and watch_uuid:
|
|
25
|
+
update_signal.send(watch_uuid=watch_uuid, status="BGP")
|
|
26
|
+
|
|
27
|
+
def fetch_bgp_info():
|
|
28
|
+
import requests
|
|
29
|
+
|
|
30
|
+
bgp_data = {}
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
# Use ip-api.com for basic ASN/ISP info (free, no key needed)
|
|
34
|
+
resp = requests.get(
|
|
35
|
+
f"http://ip-api.com/json/{ip_address}?fields=as,asname,isp,org,hosting",
|
|
36
|
+
timeout=3
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if resp.status_code == 200:
|
|
40
|
+
data = resp.json()
|
|
41
|
+
if data.get('as'):
|
|
42
|
+
bgp_data['asn'] = data['as']
|
|
43
|
+
if data.get('asname'):
|
|
44
|
+
bgp_data['as_name'] = data['asname']
|
|
45
|
+
if data.get('isp'):
|
|
46
|
+
bgp_data['isp'] = data['isp']
|
|
47
|
+
if data.get('org'):
|
|
48
|
+
bgp_data['organization'] = data['org']
|
|
49
|
+
if data.get('hosting'):
|
|
50
|
+
bgp_data['hosting'] = 'Yes' if data['hosting'] else 'No'
|
|
51
|
+
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logger.error(f"BGP info lookup failed: {e}")
|
|
54
|
+
|
|
55
|
+
# Try to get more detailed BGP info from other sources
|
|
56
|
+
# Note: Most BGP APIs require authentication or have rate limits
|
|
57
|
+
# We'll add basic info here and can expand later
|
|
58
|
+
|
|
59
|
+
return bgp_data
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
return await asyncio.to_thread(fetch_bgp_info)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f"BGP scan failed: {e}")
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def format_bgp_results(bgp_data):
|
|
69
|
+
"""Format BGP/ASN results for output"""
|
|
70
|
+
lines = []
|
|
71
|
+
lines.append("=== BGP / ASN Information ===")
|
|
72
|
+
|
|
73
|
+
if bgp_data:
|
|
74
|
+
if bgp_data.get('asn'):
|
|
75
|
+
lines.append(f"ASN: {bgp_data['asn']}")
|
|
76
|
+
if bgp_data.get('as_name'):
|
|
77
|
+
lines.append(f"AS Name: {bgp_data['as_name']}")
|
|
78
|
+
if bgp_data.get('isp'):
|
|
79
|
+
lines.append(f"ISP: {bgp_data['isp']}")
|
|
80
|
+
if bgp_data.get('organization'):
|
|
81
|
+
lines.append(f"Organization: {bgp_data['organization']}")
|
|
82
|
+
if bgp_data.get('hosting'):
|
|
83
|
+
lines.append(f"Hosting/Datacenter: {bgp_data['hosting']}")
|
|
84
|
+
else:
|
|
85
|
+
lines.append("BGP information not available")
|
|
86
|
+
|
|
87
|
+
lines.append("")
|
|
88
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DNS Reconnaissance Step
|
|
3
|
+
Performs comprehensive DNS queries using configured DNS server
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
# SOCKS5 proxy support
|
|
10
|
+
# DNS can use TCP on port 53, which works through SOCKS5
|
|
11
|
+
# We force TCP mode when proxy is configured
|
|
12
|
+
supports_socks5 = True
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def scan_dns(hostname, dns_resolver, proxy_url=None, watch_uuid=None, update_signal=None):
|
|
16
|
+
"""
|
|
17
|
+
Perform DNS reconnaissance on hostname
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
hostname: Target hostname to query
|
|
21
|
+
dns_resolver: Configured dns.resolver.Resolver instance
|
|
22
|
+
proxy_url: Optional SOCKS5 proxy URL (forces DNS-over-TCP)
|
|
23
|
+
watch_uuid: Optional watch UUID for status updates
|
|
24
|
+
update_signal: Optional blinker signal for status updates
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
dict: DNS results with record types as keys
|
|
28
|
+
"""
|
|
29
|
+
if update_signal and watch_uuid:
|
|
30
|
+
update_signal.send(watch_uuid=watch_uuid, status="DNS")
|
|
31
|
+
|
|
32
|
+
def query_dns():
|
|
33
|
+
import dns.query
|
|
34
|
+
import dns.message
|
|
35
|
+
import dns.rdatatype
|
|
36
|
+
|
|
37
|
+
results = {}
|
|
38
|
+
record_types = ['A', 'AAAA', 'MX', 'NS', 'TXT', 'SOA', 'CAA']
|
|
39
|
+
|
|
40
|
+
# Get DNS server from resolver
|
|
41
|
+
dns_server = dns_resolver.nameservers[0] if dns_resolver.nameservers else '8.8.8.8'
|
|
42
|
+
|
|
43
|
+
for rtype in record_types:
|
|
44
|
+
try:
|
|
45
|
+
# Create DNS query message
|
|
46
|
+
query = dns.message.make_query(hostname, rtype)
|
|
47
|
+
|
|
48
|
+
# If proxy is configured, use TCP (works through SOCKS5)
|
|
49
|
+
# Otherwise, use UDP (faster)
|
|
50
|
+
if proxy_url and proxy_url.strip():
|
|
51
|
+
try:
|
|
52
|
+
# DNS-over-TCP through SOCKS5 proxy
|
|
53
|
+
from python_socks.sync import Proxy
|
|
54
|
+
from urllib.parse import urlparse
|
|
55
|
+
import socket as sock_module
|
|
56
|
+
|
|
57
|
+
# Parse proxy URL
|
|
58
|
+
proxy = Proxy.from_url(proxy_url)
|
|
59
|
+
|
|
60
|
+
# Create SOCKS5 connection to DNS server
|
|
61
|
+
socks_socket = proxy.connect(dest_host=dns_server, dest_port=53)
|
|
62
|
+
|
|
63
|
+
# Send DNS query over TCP through SOCKS5
|
|
64
|
+
response = dns.query.tcp(query, dns_server, sock=socks_socket, timeout=5)
|
|
65
|
+
|
|
66
|
+
# Close socket
|
|
67
|
+
socks_socket.close()
|
|
68
|
+
|
|
69
|
+
except ImportError:
|
|
70
|
+
# CRITICAL: Do NOT fallback to direct connection - would leak real IP!
|
|
71
|
+
logger.error("SOCKS5 proxy configured but 'python-socks' not installed - DNS query BLOCKED to prevent IP leak")
|
|
72
|
+
raise Exception("DNS-over-SOCKS5 requires 'python-socks' package. Install with: pip install 'python-socks[asyncio]'")
|
|
73
|
+
except Exception as e:
|
|
74
|
+
# CRITICAL: Do NOT fallback to direct connection - would leak real IP!
|
|
75
|
+
logger.error(f"DNS-over-TCP via SOCKS5 failed for {rtype}: {e} - query BLOCKED to prevent IP leak")
|
|
76
|
+
raise
|
|
77
|
+
else:
|
|
78
|
+
# Direct UDP query (no proxy)
|
|
79
|
+
response = dns.query.udp(query, dns_server, timeout=5)
|
|
80
|
+
|
|
81
|
+
# Parse response
|
|
82
|
+
results[rtype] = []
|
|
83
|
+
for rrset in response.answer:
|
|
84
|
+
for rdata in rrset:
|
|
85
|
+
if rtype == 'MX':
|
|
86
|
+
results[rtype].append(f"{rdata.preference} {rdata.exchange}")
|
|
87
|
+
elif rtype == 'SOA':
|
|
88
|
+
results[rtype].append(f"{rdata.mname} {rdata.rname}")
|
|
89
|
+
elif rtype == 'CAA':
|
|
90
|
+
results[rtype].append(f"{rdata.flags} {rdata.tag} {rdata.value}")
|
|
91
|
+
else:
|
|
92
|
+
results[rtype].append(str(rdata))
|
|
93
|
+
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.debug(f"DNS query for {rtype} failed: {e}")
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
return results
|
|
99
|
+
|
|
100
|
+
return await asyncio.to_thread(query_dns)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def format_dns_results(dns_results):
|
|
104
|
+
"""Format DNS results for output"""
|
|
105
|
+
lines = []
|
|
106
|
+
lines.append("=== DNS Records ===")
|
|
107
|
+
|
|
108
|
+
if dns_results:
|
|
109
|
+
for rtype, records in sorted(dns_results.items()):
|
|
110
|
+
if records:
|
|
111
|
+
lines.append(f"{rtype} Records:")
|
|
112
|
+
|
|
113
|
+
# Sort records based on type
|
|
114
|
+
if rtype == 'MX':
|
|
115
|
+
# MX records: sort by priority (numeric), then alphabetically
|
|
116
|
+
# Format: "10 mail.example.com."
|
|
117
|
+
def mx_sort_key(mx_record):
|
|
118
|
+
try:
|
|
119
|
+
parts = mx_record.split(' ', 1)
|
|
120
|
+
priority = int(parts[0])
|
|
121
|
+
server = parts[1] if len(parts) > 1 else ''
|
|
122
|
+
return (priority, server)
|
|
123
|
+
except:
|
|
124
|
+
return (999999, mx_record)
|
|
125
|
+
|
|
126
|
+
sorted_records = sorted(records, key=mx_sort_key)
|
|
127
|
+
|
|
128
|
+
elif rtype == 'TXT':
|
|
129
|
+
# TXT records: sort alphabetically
|
|
130
|
+
sorted_records = sorted(records)
|
|
131
|
+
|
|
132
|
+
elif rtype == 'NS':
|
|
133
|
+
# NS records: sort alphabetically for consistent ordering
|
|
134
|
+
sorted_records = sorted(records)
|
|
135
|
+
|
|
136
|
+
else:
|
|
137
|
+
# Other records: keep original order
|
|
138
|
+
sorted_records = records
|
|
139
|
+
|
|
140
|
+
# Output sorted records
|
|
141
|
+
for record in sorted_records:
|
|
142
|
+
lines.append(f" {record}")
|
|
143
|
+
else:
|
|
144
|
+
lines.append("No DNS records found")
|
|
145
|
+
|
|
146
|
+
lines.append("")
|
|
147
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DNS Reconnaissance Step
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from .base import ScanStep
|
|
8
|
+
from .registry import register_step
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register_step
|
|
12
|
+
class DNSScanStep(ScanStep):
|
|
13
|
+
"""DNS record queries (A, AAAA, MX, NS, TXT, SOA, CAA)"""
|
|
14
|
+
|
|
15
|
+
name = "DNS Records"
|
|
16
|
+
order = 10
|
|
17
|
+
|
|
18
|
+
async def scan(self, context: dict):
|
|
19
|
+
"""Perform DNS reconnaissance"""
|
|
20
|
+
hostname = context['hostname']
|
|
21
|
+
dns_resolver = context['dns_resolver']
|
|
22
|
+
watch_uuid = context.get('watch_uuid')
|
|
23
|
+
update_signal = context.get('update_signal')
|
|
24
|
+
|
|
25
|
+
if update_signal and watch_uuid:
|
|
26
|
+
update_signal.send(watch_uuid=watch_uuid, status="DNS")
|
|
27
|
+
|
|
28
|
+
def query_dns():
|
|
29
|
+
results = {}
|
|
30
|
+
record_types = ['A', 'AAAA', 'MX', 'NS', 'TXT', 'SOA', 'CAA']
|
|
31
|
+
|
|
32
|
+
for rtype in record_types:
|
|
33
|
+
try:
|
|
34
|
+
answers = dns_resolver.resolve(hostname, rtype)
|
|
35
|
+
results[rtype] = []
|
|
36
|
+
for rdata in answers:
|
|
37
|
+
if rtype == 'MX':
|
|
38
|
+
results[rtype].append(f"{rdata.preference} {rdata.exchange}")
|
|
39
|
+
elif rtype == 'SOA':
|
|
40
|
+
results[rtype].append(f"{rdata.mname} {rdata.rname}")
|
|
41
|
+
elif rtype == 'CAA':
|
|
42
|
+
results[rtype].append(f"{rdata.flags} {rdata.tag} {rdata.value}")
|
|
43
|
+
else:
|
|
44
|
+
results[rtype].append(str(rdata))
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.debug(f"DNS query for {rtype} failed: {e}")
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
return results
|
|
50
|
+
|
|
51
|
+
return await asyncio.to_thread(query_dns)
|
|
52
|
+
|
|
53
|
+
def format_results(self, dns_results):
|
|
54
|
+
"""Format DNS results"""
|
|
55
|
+
lines = []
|
|
56
|
+
lines.append("=== DNS Records ===")
|
|
57
|
+
|
|
58
|
+
if dns_results and not isinstance(dns_results, Exception):
|
|
59
|
+
for rtype, records in sorted(dns_results.items()):
|
|
60
|
+
if records:
|
|
61
|
+
lines.append(f"{rtype} Records:")
|
|
62
|
+
|
|
63
|
+
# Sort records based on type for consistent output
|
|
64
|
+
if rtype == 'MX':
|
|
65
|
+
# MX records: sort by priority (numeric), then alphabetically
|
|
66
|
+
def mx_sort_key(mx_record):
|
|
67
|
+
try:
|
|
68
|
+
parts = mx_record.split(' ', 1)
|
|
69
|
+
priority = int(parts[0])
|
|
70
|
+
server = parts[1] if len(parts) > 1 else ''
|
|
71
|
+
return (priority, server)
|
|
72
|
+
except:
|
|
73
|
+
return (999999, mx_record)
|
|
74
|
+
sorted_records = sorted(records, key=mx_sort_key)
|
|
75
|
+
elif rtype in ['NS', 'TXT']:
|
|
76
|
+
# NS and TXT records: sort alphabetically for consistent ordering
|
|
77
|
+
sorted_records = sorted(records)
|
|
78
|
+
else:
|
|
79
|
+
# Other records: keep original order
|
|
80
|
+
sorted_records = records
|
|
81
|
+
|
|
82
|
+
for record in sorted_records:
|
|
83
|
+
lines.append(f" {record}")
|
|
84
|
+
else:
|
|
85
|
+
lines.append("No DNS records found")
|
|
86
|
+
|
|
87
|
+
lines.append("")
|
|
88
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DNSSEC Validation Step
|
|
3
|
+
Validates DNSSEC signatures and chain of trust for domain security
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
# SOCKS5 proxy support: Requires DNS-over-TCP implementation (TODO: use dns.query.tcp with SOCKS5 socket)
|
|
8
|
+
supports_socks5 = False
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def scan_dnssec(hostname, dns_resolver, watch_uuid=None, update_signal=None):
|
|
14
|
+
"""
|
|
15
|
+
Perform DNSSEC validation
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
hostname: Target hostname to validate
|
|
19
|
+
dns_resolver: Configured dns.resolver.Resolver instance
|
|
20
|
+
watch_uuid: Optional watch UUID for status updates
|
|
21
|
+
update_signal: Optional blinker signal for status updates
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
dict: DNSSEC validation results
|
|
25
|
+
"""
|
|
26
|
+
if update_signal and watch_uuid:
|
|
27
|
+
update_signal.send(watch_uuid=watch_uuid, status="DNSSEC")
|
|
28
|
+
|
|
29
|
+
def query_dnssec():
|
|
30
|
+
import dns.dnssec
|
|
31
|
+
import dns.name
|
|
32
|
+
|
|
33
|
+
results = {
|
|
34
|
+
'dnssec_enabled': False,
|
|
35
|
+
'has_dnskey': False,
|
|
36
|
+
'has_ds': False,
|
|
37
|
+
'has_rrsig': False,
|
|
38
|
+
'validation_status': 'unknown',
|
|
39
|
+
'algorithm': None,
|
|
40
|
+
'key_count': 0,
|
|
41
|
+
'rrsig_count': 0,
|
|
42
|
+
'signatures': [],
|
|
43
|
+
'keys': [],
|
|
44
|
+
'error': None
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
domain_name = dns.name.from_text(hostname)
|
|
49
|
+
|
|
50
|
+
# === Check for DNSKEY records (public keys) ===
|
|
51
|
+
try:
|
|
52
|
+
dnskey_answers = dns_resolver.resolve(hostname, 'DNSKEY')
|
|
53
|
+
results['has_dnskey'] = True
|
|
54
|
+
results['key_count'] = len(dnskey_answers)
|
|
55
|
+
|
|
56
|
+
for rdata in dnskey_answers:
|
|
57
|
+
# DNSKEY flags: 256 = Zone Signing Key (ZSK), 257 = Key Signing Key (KSK)
|
|
58
|
+
key_type = "KSK (Key Signing Key)" if rdata.flags == 257 else "ZSK (Zone Signing Key)" if rdata.flags == 256 else f"Unknown (flags={rdata.flags})"
|
|
59
|
+
|
|
60
|
+
# Algorithm mapping (common ones)
|
|
61
|
+
alg_names = {
|
|
62
|
+
5: "RSA/SHA-1",
|
|
63
|
+
7: "RSASHA1-NSEC3-SHA1",
|
|
64
|
+
8: "RSA/SHA-256",
|
|
65
|
+
10: "RSA/SHA-512",
|
|
66
|
+
13: "ECDSA Curve P-256 with SHA-256",
|
|
67
|
+
14: "ECDSA Curve P-384 with SHA-384",
|
|
68
|
+
15: "Ed25519",
|
|
69
|
+
16: "Ed448"
|
|
70
|
+
}
|
|
71
|
+
algorithm = alg_names.get(rdata.algorithm, f"Algorithm {rdata.algorithm}")
|
|
72
|
+
|
|
73
|
+
results['keys'].append({
|
|
74
|
+
'type': key_type,
|
|
75
|
+
'algorithm': algorithm,
|
|
76
|
+
'flags': rdata.flags,
|
|
77
|
+
'protocol': rdata.protocol,
|
|
78
|
+
'key_tag': dns.dnssec.key_id(rdata)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if not results['algorithm']:
|
|
82
|
+
results['algorithm'] = algorithm
|
|
83
|
+
|
|
84
|
+
logger.debug(f"Found {results['key_count']} DNSKEY records for {hostname}")
|
|
85
|
+
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.debug(f"No DNSKEY records found: {e}")
|
|
88
|
+
|
|
89
|
+
# === Check for DS records (delegation signer) ===
|
|
90
|
+
# DS records exist at the parent zone, proving the child is signed
|
|
91
|
+
try:
|
|
92
|
+
ds_answers = dns_resolver.resolve(hostname, 'DS')
|
|
93
|
+
results['has_ds'] = len(ds_answers) > 0
|
|
94
|
+
logger.debug(f"Found DS records for {hostname}")
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.debug(f"No DS records found: {e}")
|
|
97
|
+
|
|
98
|
+
# === Check for RRSIG records (signatures) ===
|
|
99
|
+
# RRSIG records sign other record types (A, AAAA, etc.)
|
|
100
|
+
try:
|
|
101
|
+
# Try to get RRSIG for A records
|
|
102
|
+
a_answers = dns_resolver.resolve(hostname, 'A')
|
|
103
|
+
# Get the RRSIG that covers the A records
|
|
104
|
+
rrsig_answers = dns_resolver.resolve(hostname, 'RRSIG', rdtype=dns.rdatatype.A)
|
|
105
|
+
results['has_rrsig'] = True
|
|
106
|
+
results['rrsig_count'] = len(rrsig_answers)
|
|
107
|
+
|
|
108
|
+
for rdata in rrsig_answers:
|
|
109
|
+
# Parse signature timing
|
|
110
|
+
inception_time = datetime.fromtimestamp(rdata.inception)
|
|
111
|
+
expiration_time = datetime.fromtimestamp(rdata.expiration)
|
|
112
|
+
now = datetime.now()
|
|
113
|
+
|
|
114
|
+
# Check if signature is currently valid
|
|
115
|
+
is_valid = inception_time <= now <= expiration_time
|
|
116
|
+
|
|
117
|
+
alg_names = {
|
|
118
|
+
5: "RSA/SHA-1", 7: "RSASHA1-NSEC3-SHA1", 8: "RSA/SHA-256",
|
|
119
|
+
10: "RSA/SHA-512", 13: "ECDSA P-256", 14: "ECDSA P-384",
|
|
120
|
+
15: "Ed25519", 16: "Ed448"
|
|
121
|
+
}
|
|
122
|
+
algorithm = alg_names.get(rdata.algorithm, f"Algorithm {rdata.algorithm}")
|
|
123
|
+
|
|
124
|
+
results['signatures'].append({
|
|
125
|
+
'type_covered': dns.rdatatype.to_text(rdata.type_covered),
|
|
126
|
+
'algorithm': algorithm,
|
|
127
|
+
'signer': str(rdata.signer),
|
|
128
|
+
'key_tag': rdata.key_tag,
|
|
129
|
+
'inception': inception_time.strftime('%Y-%m-%d %H:%M:%S'),
|
|
130
|
+
'expiration': expiration_time.strftime('%Y-%m-%d %H:%M:%S'),
|
|
131
|
+
'valid': is_valid,
|
|
132
|
+
'days_until_expiry': (expiration_time - now).days if is_valid else None
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
logger.debug(f"Found {results['rrsig_count']} RRSIG records for {hostname}")
|
|
136
|
+
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.debug(f"No RRSIG records found: {e}")
|
|
139
|
+
|
|
140
|
+
# === Determine overall DNSSEC status ===
|
|
141
|
+
if results['has_dnskey'] and results['has_rrsig']:
|
|
142
|
+
results['dnssec_enabled'] = True
|
|
143
|
+
# Full chain validation requires DS records in parent zone
|
|
144
|
+
if results['has_ds']:
|
|
145
|
+
results['validation_status'] = 'secure (full chain)'
|
|
146
|
+
else:
|
|
147
|
+
results['validation_status'] = 'secure (no DS records in parent zone)'
|
|
148
|
+
elif results['has_dnskey'] or results['has_rrsig']:
|
|
149
|
+
results['dnssec_enabled'] = True
|
|
150
|
+
results['validation_status'] = 'partial (incomplete DNSSEC)'
|
|
151
|
+
else:
|
|
152
|
+
results['dnssec_enabled'] = False
|
|
153
|
+
results['validation_status'] = 'unsigned (no DNSSEC)'
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.error(f"DNSSEC validation error: {e}")
|
|
157
|
+
results['error'] = str(e)
|
|
158
|
+
results['validation_status'] = 'error'
|
|
159
|
+
|
|
160
|
+
return results
|
|
161
|
+
|
|
162
|
+
return await asyncio.to_thread(query_dnssec)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def format_dnssec_results(dnssec_results):
|
|
166
|
+
"""Format DNSSEC validation results for output"""
|
|
167
|
+
lines = []
|
|
168
|
+
lines.append("=== DNSSEC Validation ===")
|
|
169
|
+
|
|
170
|
+
if not dnssec_results:
|
|
171
|
+
lines.append("DNSSEC validation failed")
|
|
172
|
+
lines.append("")
|
|
173
|
+
return '\n'.join(lines)
|
|
174
|
+
|
|
175
|
+
if dnssec_results.get('error'):
|
|
176
|
+
lines.append(f"Error: {dnssec_results['error']}")
|
|
177
|
+
lines.append("")
|
|
178
|
+
return '\n'.join(lines)
|
|
179
|
+
|
|
180
|
+
# Overall Status
|
|
181
|
+
lines.append("")
|
|
182
|
+
status = dnssec_results.get('validation_status', 'unknown')
|
|
183
|
+
if 'secure' in status:
|
|
184
|
+
lines.append(f"Status: ✓ DNSSEC Enabled - {status}")
|
|
185
|
+
lines.append("Security: ✓ DNS responses are cryptographically signed")
|
|
186
|
+
elif 'partial' in status:
|
|
187
|
+
lines.append(f"Status: ⚠ {status}")
|
|
188
|
+
lines.append("Security: ⚠ DNSSEC is partially configured")
|
|
189
|
+
elif 'unsigned' in status:
|
|
190
|
+
lines.append(f"Status: ✗ {status}")
|
|
191
|
+
lines.append("Security: ✗ DNS responses are NOT signed (vulnerable to DNS spoofing)")
|
|
192
|
+
else:
|
|
193
|
+
lines.append(f"Status: ? {status}")
|
|
194
|
+
|
|
195
|
+
# DNSKEY Records (Public Keys)
|
|
196
|
+
if dnssec_results.get('has_dnskey'):
|
|
197
|
+
lines.append("")
|
|
198
|
+
lines.append(f"DNSKEY Records: ✓ Found {dnssec_results.get('key_count', 0)} key(s)")
|
|
199
|
+
for idx, key in enumerate(dnssec_results.get('keys', []), 1):
|
|
200
|
+
lines.append(f" Key {idx}:")
|
|
201
|
+
lines.append(f" Type: {key['type']}")
|
|
202
|
+
lines.append(f" Algorithm: {key['algorithm']}")
|
|
203
|
+
lines.append(f" Key Tag: {key['key_tag']}")
|
|
204
|
+
else:
|
|
205
|
+
lines.append("")
|
|
206
|
+
lines.append("DNSKEY Records: ✗ Not found")
|
|
207
|
+
|
|
208
|
+
# DS Records (Delegation Signer)
|
|
209
|
+
if dnssec_results.get('has_ds'):
|
|
210
|
+
lines.append("")
|
|
211
|
+
lines.append("DS Records: ✓ Found in parent zone")
|
|
212
|
+
lines.append(" Chain of Trust: ✓ Domain is properly delegated from parent")
|
|
213
|
+
else:
|
|
214
|
+
lines.append("")
|
|
215
|
+
lines.append("DS Records: ✗ Not found in parent zone")
|
|
216
|
+
if dnssec_results.get('has_dnskey'):
|
|
217
|
+
lines.append(" Chain of Trust: ⚠ Keys exist but not published to parent zone")
|
|
218
|
+
|
|
219
|
+
# RRSIG Records (Signatures)
|
|
220
|
+
if dnssec_results.get('has_rrsig'):
|
|
221
|
+
lines.append("")
|
|
222
|
+
lines.append(f"RRSIG Records: ✓ Found {dnssec_results.get('rrsig_count', 0)} signature(s)")
|
|
223
|
+
|
|
224
|
+
for idx, sig in enumerate(dnssec_results.get('signatures', []), 1):
|
|
225
|
+
lines.append(f" Signature {idx}:")
|
|
226
|
+
lines.append(f" Covers: {sig['type_covered']} records")
|
|
227
|
+
lines.append(f" Algorithm: {sig['algorithm']}")
|
|
228
|
+
lines.append(f" Signer: {sig['signer']}")
|
|
229
|
+
lines.append(f" Key Tag: {sig['key_tag']}")
|
|
230
|
+
lines.append(f" Valid From: {sig['inception']}")
|
|
231
|
+
lines.append(f" Expires: {sig['expiration']}")
|
|
232
|
+
|
|
233
|
+
if sig['valid']:
|
|
234
|
+
days = sig.get('days_until_expiry')
|
|
235
|
+
if days is not None:
|
|
236
|
+
if days <= 7:
|
|
237
|
+
lines.append(f" Status: ⚠ Valid but expires in {days} days")
|
|
238
|
+
else:
|
|
239
|
+
lines.append(f" Status: ✓ Valid ({days} days remaining)")
|
|
240
|
+
else:
|
|
241
|
+
lines.append(" Status: ✗ EXPIRED or not yet valid")
|
|
242
|
+
else:
|
|
243
|
+
lines.append("")
|
|
244
|
+
lines.append("RRSIG Records: ✗ Not found")
|
|
245
|
+
|
|
246
|
+
# Summary and Recommendations
|
|
247
|
+
lines.append("")
|
|
248
|
+
lines.append("DNSSEC Summary:")
|
|
249
|
+
if dnssec_results.get('dnssec_enabled'):
|
|
250
|
+
if dnssec_results.get('has_ds'):
|
|
251
|
+
lines.append(" ✓ Full DNSSEC deployment with proper chain of trust")
|
|
252
|
+
else:
|
|
253
|
+
lines.append(" ⚠ DNSSEC configured but DS records not published to parent")
|
|
254
|
+
lines.append(" Recommendation: Publish DS records to parent zone for full validation")
|
|
255
|
+
else:
|
|
256
|
+
lines.append(" ✗ DNSSEC not configured")
|
|
257
|
+
lines.append(" Recommendation: Enable DNSSEC to protect against DNS spoofing/cache poisoning")
|
|
258
|
+
|
|
259
|
+
lines.append("")
|
|
260
|
+
return '\n'.join(lines)
|