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.
Files changed (29) hide show
  1. changedetection_io_osint_processor-0.0.1.dist-info/METADATA +274 -0
  2. changedetection_io_osint_processor-0.0.1.dist-info/RECORD +29 -0
  3. changedetection_io_osint_processor-0.0.1.dist-info/WHEEL +5 -0
  4. changedetection_io_osint_processor-0.0.1.dist-info/entry_points.txt +2 -0
  5. changedetection_io_osint_processor-0.0.1.dist-info/licenses/LICENSE +661 -0
  6. changedetection_io_osint_processor-0.0.1.dist-info/top_level.txt +1 -0
  7. changedetectionio_osint/__init__.py +22 -0
  8. changedetectionio_osint/forms.py +289 -0
  9. changedetectionio_osint/plugin.py +37 -0
  10. changedetectionio_osint/processor.py +655 -0
  11. changedetectionio_osint/steps/__init__.py +4 -0
  12. changedetectionio_osint/steps/base.py +76 -0
  13. changedetectionio_osint/steps/bgp.py +88 -0
  14. changedetectionio_osint/steps/dns.py +147 -0
  15. changedetectionio_osint/steps/dns_scan.py +88 -0
  16. changedetectionio_osint/steps/dnssec.py +260 -0
  17. changedetectionio_osint/steps/email_security.py +236 -0
  18. changedetectionio_osint/steps/http_fingerprint.py +359 -0
  19. changedetectionio_osint/steps/http_scan.py +31 -0
  20. changedetectionio_osint/steps/mac_lookup.py +209 -0
  21. changedetectionio_osint/steps/os_detection.py +245 -0
  22. changedetectionio_osint/steps/portscan.py +113 -0
  23. changedetectionio_osint/steps/registry.py +49 -0
  24. changedetectionio_osint/steps/smtp_fingerprint.py +517 -0
  25. changedetectionio_osint/steps/ssh_fingerprint.py +310 -0
  26. changedetectionio_osint/steps/tls_analysis.py +332 -0
  27. changedetectionio_osint/steps/traceroute.py +127 -0
  28. changedetectionio_osint/steps/whois_lookup.py +125 -0
  29. 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)