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,517 @@
1
+ """
2
+ SMTP/Email Server Fingerprinting Step
3
+ Connects to MX (Mail Exchange) servers to extract server banner, capabilities, and security features
4
+ """
5
+
6
+ import asyncio
7
+ # SOCKS5 proxy support: SMTP fingerprinting supports SOCKS5 via python-socks
8
+ supports_socks5 = True
9
+ import socket
10
+ from loguru import logger
11
+
12
+
13
+ async def scan_smtp_mx_records(mx_records, dns_resolver, ports=[25, 587, 465], timeout=5, proxy_url=None, ehlo_hostname='localhost.localdomain', watch_uuid=None, update_signal=None):
14
+ """
15
+ Perform SMTP server fingerprinting on MX (Mail Exchange) records
16
+
17
+ Args:
18
+ mx_records: List of MX record strings (format: "10 mail.example.com.")
19
+ dns_resolver: Configured dns.resolver.Resolver instance for resolving MX hostnames
20
+ ports: List of SMTP ports to check (default: [25, 587, 465])
21
+ timeout: Connection timeout in seconds
22
+ proxy_url: Optional SOCKS5 proxy URL (socks5://user:pass@host:port)
23
+ ehlo_hostname: Hostname to use in SMTP EHLO command (default: localhost.localdomain)
24
+ watch_uuid: Optional watch UUID for status updates
25
+ update_signal: Optional blinker signal for status updates
26
+
27
+ Returns:
28
+ dict: SMTP fingerprint data for all MX servers
29
+ """
30
+ if not mx_records:
31
+ return {
32
+ 'mx_servers': [],
33
+ 'no_mx_records': True
34
+ }
35
+
36
+ # Parse MX records (format: "10 mail.example.com.")
37
+ mx_servers = []
38
+ for mx_record in mx_records:
39
+ try:
40
+ parts = mx_record.split(' ', 1)
41
+ if len(parts) == 2:
42
+ priority = int(parts[0])
43
+ hostname = parts[1].rstrip('.')
44
+ mx_servers.append({'priority': priority, 'hostname': hostname})
45
+ except Exception as e:
46
+ logger.debug(f"Failed to parse MX record '{mx_record}': {e}")
47
+
48
+ if not mx_servers:
49
+ return {
50
+ 'mx_servers': [],
51
+ 'no_mx_records': True
52
+ }
53
+
54
+ # Sort by priority (lower number = higher priority)
55
+ mx_servers.sort(key=lambda x: x['priority'])
56
+
57
+ async def smtp_fingerprint_mx_server(mx_server):
58
+ """Fingerprint SMTP on a specific MX server"""
59
+ hostname = mx_server['hostname']
60
+ priority = mx_server['priority']
61
+
62
+ # Resolve MX hostname to IP (unless using SOCKS5 proxy)
63
+ # CRITICAL: When using SOCKS5, pass hostname to proxy - don't resolve locally (DNS leak!)
64
+ if proxy_url and proxy_url.strip():
65
+ # Using SOCKS5: Let proxy resolve hostname remotely - no local DNS leak
66
+ ip_address = None # Will pass hostname to connection instead
67
+ target_host = hostname # Connect to hostname via SOCKS5
68
+ logger.debug(f"Scanning MX server {hostname} via SOCKS5 (remote DNS) priority {priority}")
69
+ else:
70
+ # No proxy: Resolve locally
71
+ try:
72
+ answers = dns_resolver.resolve(hostname, 'A')
73
+ ip_address = str(answers[0])
74
+ except Exception:
75
+ try:
76
+ answers = dns_resolver.resolve(hostname, 'AAAA')
77
+ ip_address = str(answers[0])
78
+ except Exception as e:
79
+ logger.debug(f"Failed to resolve MX server {hostname}: {e}")
80
+ return {
81
+ 'mx_hostname': hostname,
82
+ 'priority': priority,
83
+ 'ip_address': None,
84
+ 'error': f"DNS resolution failed: {e}",
85
+ 'port_results': []
86
+ }
87
+ target_host = ip_address
88
+ logger.debug(f"Scanning MX server {hostname} ({ip_address}) priority {priority}")
89
+
90
+ if update_signal and watch_uuid:
91
+ status_msg = f"SMTP: {hostname}" + (f" ({ip_address})" if ip_address else " (via SOCKS5)")
92
+ update_signal.send(watch_uuid=watch_uuid, status=status_msg)
93
+
94
+ # Scan all ports on this MX server
95
+ port_tasks = [smtp_fingerprint_port(target_host, port, hostname, timeout, proxy_url, ehlo_hostname) for port in ports]
96
+ port_results = await asyncio.gather(*port_tasks, return_exceptions=True)
97
+
98
+ return {
99
+ 'mx_hostname': hostname,
100
+ 'priority': priority,
101
+ 'ip_address': ip_address,
102
+ 'port_results': [r for r in port_results if not isinstance(r, Exception)],
103
+ 'open_ports': [r['port'] for r in port_results if not isinstance(r, Exception) and r.get('port_open')]
104
+ }
105
+
106
+ async def smtp_fingerprint_port(target_host, port, mx_hostname, timeout, proxy_url=None, ehlo_hostname='localhost.localdomain'):
107
+ """
108
+ Fingerprint SMTP on a specific port
109
+
110
+ Args:
111
+ target_host: IP address (no proxy) or hostname (with SOCKS5 proxy for remote DNS)
112
+ port: SMTP port to scan
113
+ mx_hostname: MX hostname for logging
114
+ timeout: Connection timeout
115
+ proxy_url: Optional SOCKS5 proxy URL
116
+ ehlo_hostname: Hostname to use in SMTP EHLO command
117
+ """
118
+ result = {
119
+ 'port': port,
120
+ 'port_open': False,
121
+ 'banner': None,
122
+ 'server': None,
123
+ 'ehlo_response': [],
124
+ 'capabilities': [],
125
+ 'supports_starttls': False,
126
+ 'supports_auth': False,
127
+ 'auth_methods': [],
128
+ 'supports_pipelining': False,
129
+ 'supports_smtputf8': False,
130
+ 'supports_chunking': False,
131
+ 'supports_8bitmime': False,
132
+ 'max_message_size': None,
133
+ 'is_ssl_wrapped': port == 465, # Port 465 is implicit TLS
134
+ 'error': None
135
+ }
136
+
137
+ try:
138
+ # Connect to SMTP port (with optional SOCKS5 proxy support)
139
+ if proxy_url and proxy_url.strip():
140
+ # Use SOCKS5 proxy for connection
141
+ try:
142
+ from python_socks.async_.asyncio import Proxy
143
+
144
+ # Connect through SOCKS5 proxy
145
+ # target_host is hostname - proxy will resolve it remotely (no DNS leak)
146
+ proxy = Proxy.from_url(proxy_url)
147
+ sock = await asyncio.wait_for(
148
+ proxy.connect(dest_host=target_host, dest_port=port, timeout=timeout),
149
+ timeout=timeout
150
+ )
151
+
152
+ # Create reader/writer from the socket
153
+ reader, writer = await asyncio.open_connection(sock=sock)
154
+
155
+ except ImportError:
156
+ logger.error("SOCKS5 proxy requested but 'python-socks[asyncio]' is not installed")
157
+ result['error'] = "SOCKS5 proxy support requires 'python-socks[asyncio]' package"
158
+ return result
159
+ else:
160
+ # Direct connection (no proxy)
161
+ # target_host is IP address (already resolved)
162
+ reader, writer = await asyncio.wait_for(
163
+ asyncio.open_connection(target_host, port),
164
+ timeout=timeout
165
+ )
166
+
167
+ result['port_open'] = True
168
+
169
+ try:
170
+ # Read SMTP banner (220 response)
171
+ banner_line = await asyncio.wait_for(reader.readline(), timeout=timeout)
172
+ banner = banner_line.decode('utf-8', errors='ignore').strip()
173
+ result['banner'] = banner
174
+
175
+ # Parse server from banner (format: "220 hostname ESMTP server-software")
176
+ if ' ESMTP ' in banner or ' SMTP ' in banner:
177
+ parts = banner.split(' ', 3)
178
+ if len(parts) >= 3:
179
+ result['server'] = parts[2] if len(parts) == 3 else parts[3]
180
+
181
+ logger.debug(f"SMTP banner on {mx_hostname}:{port}: {banner}")
182
+
183
+ # Send EHLO command to get capabilities
184
+ # Use configured hostname (default: localhost.localdomain) for privacy
185
+ ehlo_cmd = f"EHLO {ehlo_hostname}\r\n"
186
+ writer.write(ehlo_cmd.encode())
187
+ await writer.drain()
188
+
189
+ # Read EHLO response (multiple lines, ends with code without -)
190
+ ehlo_lines = []
191
+ while True:
192
+ line = await asyncio.wait_for(reader.readline(), timeout=timeout)
193
+ line_str = line.decode('utf-8', errors='ignore').strip()
194
+ ehlo_lines.append(line_str)
195
+
196
+ # SMTP multiline responses: "250-" continues, "250 " ends
197
+ if line_str and len(line_str) >= 4 and line_str[3] == ' ':
198
+ break
199
+ if len(ehlo_lines) > 50: # Safety limit
200
+ break
201
+
202
+ result['ehlo_response'] = ehlo_lines
203
+
204
+ # Parse capabilities from EHLO response
205
+ for line in ehlo_lines:
206
+ # Skip the first line (usually just "250-hostname")
207
+ if not line.startswith('250'):
208
+ continue
209
+
210
+ # Remove "250-" or "250 " prefix
211
+ capability = line[4:].strip() if len(line) > 4 else ''
212
+
213
+ if capability:
214
+ result['capabilities'].append(capability)
215
+
216
+ # Parse specific capabilities
217
+ cap_upper = capability.upper()
218
+
219
+ if cap_upper.startswith('STARTTLS'):
220
+ result['supports_starttls'] = True
221
+ elif cap_upper.startswith('AUTH'):
222
+ result['supports_auth'] = True
223
+ # Parse auth methods (format: "AUTH LOGIN PLAIN CRAM-MD5")
224
+ auth_parts = capability.split()
225
+ if len(auth_parts) > 1:
226
+ result['auth_methods'] = auth_parts[1:]
227
+ elif cap_upper.startswith('PIPELINING'):
228
+ result['supports_pipelining'] = True
229
+ elif cap_upper.startswith('SMTPUTF8'):
230
+ result['supports_smtputf8'] = True
231
+ elif cap_upper.startswith('CHUNKING'):
232
+ result['supports_chunking'] = True
233
+ elif cap_upper.startswith('8BITMIME'):
234
+ result['supports_8bitmime'] = True
235
+ elif cap_upper.startswith('SIZE'):
236
+ # Parse max message size (format: "SIZE 52428800")
237
+ size_parts = capability.split()
238
+ if len(size_parts) > 1:
239
+ try:
240
+ result['max_message_size'] = int(size_parts[1])
241
+ except ValueError:
242
+ pass
243
+
244
+ # Send QUIT command
245
+ writer.write(b"QUIT\r\n")
246
+ await writer.drain()
247
+
248
+ finally:
249
+ writer.close()
250
+ await writer.wait_closed()
251
+
252
+ except asyncio.TimeoutError:
253
+ result['error'] = f"Connection timeout"
254
+ logger.debug(f"SMTP connection timeout: {target_host}:{port}")
255
+ except ConnectionRefusedError:
256
+ result['error'] = f"Connection refused"
257
+ except socket.gaierror as e:
258
+ result['error'] = f"DNS error: {e}"
259
+ except Exception as e:
260
+ result['error'] = f"Connection error: {str(e)}"
261
+ logger.debug(f"SMTP fingerprint error on {target_host}:{port}: {e}")
262
+
263
+ return result
264
+
265
+ # Scan all MX servers in parallel
266
+ results = await asyncio.gather(*[smtp_fingerprint_mx_server(mx) for mx in mx_servers])
267
+
268
+ return {
269
+ 'mx_servers': results,
270
+ 'total_mx_count': len(mx_servers),
271
+ 'no_mx_records': False
272
+ }
273
+
274
+
275
+ def format_smtp_results(smtp_results):
276
+ """Format SMTP fingerprint results for output"""
277
+ lines = []
278
+ lines.append("=== SMTP/Email Server Fingerprint ===")
279
+
280
+ if not smtp_results:
281
+ lines.append("No SMTP scan results")
282
+ lines.append("")
283
+ return '\n'.join(lines)
284
+
285
+ if smtp_results.get('no_mx_records'):
286
+ lines.append("Status: ✗ No MX (Mail Exchange) records found")
287
+ lines.append("Note: This domain may not accept email, or MX records are not configured")
288
+ lines.append("")
289
+ return '\n'.join(lines)
290
+
291
+ mx_servers = smtp_results.get('mx_servers', [])
292
+ if not mx_servers:
293
+ lines.append("Status: ✗ No MX servers to scan")
294
+ lines.append("")
295
+ return '\n'.join(lines)
296
+
297
+ # Count how many MX servers have open ports
298
+ mx_with_open_ports = [mx for mx in mx_servers if mx.get('open_ports')]
299
+
300
+ if not mx_with_open_ports:
301
+ lines.append(f"Status: ✗ No SMTP ports open on {len(mx_servers)} MX server(s)")
302
+ lines.append("")
303
+ # Still show the MX servers that were checked
304
+ for mx in mx_servers:
305
+ lines.append(f"MX Server: {mx.get('mx_hostname')} (priority {mx.get('priority')})")
306
+ if mx.get('error'):
307
+ lines.append(f" Error: {mx['error']}")
308
+ lines.append("")
309
+ return '\n'.join(lines)
310
+
311
+ # Summary
312
+ lines.append(f"Status: ✓ Found {len(mx_with_open_ports)} active MX server(s) out of {len(mx_servers)} total")
313
+ lines.append("")
314
+
315
+ # Detail each MX server
316
+ for mx in mx_servers:
317
+ mx_hostname = mx.get('mx_hostname', 'unknown')
318
+ priority = mx.get('priority', '?')
319
+ ip_address = mx.get('ip_address', 'unresolved')
320
+
321
+ lines.append("=" * 70)
322
+ lines.append(f"MX Server: {mx_hostname}")
323
+ lines.append(f"Priority: {priority} (lower = higher priority)")
324
+ lines.append(f"IP Address: {ip_address}")
325
+ lines.append("=" * 70)
326
+
327
+ if mx.get('error'):
328
+ lines.append(f"Error: {mx['error']}")
329
+ lines.append("")
330
+ continue
331
+
332
+ port_results = mx.get('port_results', [])
333
+ open_ports = mx.get('open_ports', [])
334
+
335
+ if not open_ports:
336
+ lines.append("Status: ✗ No SMTP ports responding")
337
+ lines.append("")
338
+ continue
339
+
340
+ lines.append(f"Open Ports: {', '.join(map(str, open_ports))}")
341
+ lines.append("")
342
+
343
+ # Detail each port
344
+ for result in port_results:
345
+ if not result.get('port_open'):
346
+ continue
347
+
348
+ port = result['port']
349
+ lines.append(f"--- Port {port} ---")
350
+
351
+ # Port type description
352
+ if port == 25:
353
+ lines.append(" Port Type: SMTP (standard mail transfer)")
354
+ elif port == 587:
355
+ lines.append(" Port Type: Submission (client mail submission, usually requires auth)")
356
+ elif port == 465:
357
+ lines.append(" Port Type: SMTPS (implicit TLS/SSL)")
358
+ else:
359
+ lines.append(f" Port Type: Custom SMTP port")
360
+
361
+ # Banner and server
362
+ if result.get('banner'):
363
+ lines.append(f" Banner: {result['banner']}")
364
+ if result.get('server'):
365
+ lines.append(f" Server Software: {result['server']}")
366
+
367
+ # Identify server type
368
+ server = result['server'].lower()
369
+ if 'postfix' in server:
370
+ lines.append(" Server Type: Postfix (popular open-source MTA)")
371
+ elif 'exim' in server:
372
+ lines.append(" Server Type: Exim (flexible Unix MTA)")
373
+ elif 'sendmail' in server:
374
+ lines.append(" Server Type: Sendmail (classic Unix MTA)")
375
+ elif 'microsoft' in server or 'exchange' in server:
376
+ lines.append(" Server Type: Microsoft Exchange")
377
+ elif 'zimbra' in server:
378
+ lines.append(" Server Type: Zimbra Collaboration Suite")
379
+ elif 'qmail' in server:
380
+ lines.append(" Server Type: Qmail (secure MTA)")
381
+ elif 'haraka' in server:
382
+ lines.append(" Server Type: Haraka (modern Node.js MTA)")
383
+
384
+ # Capabilities
385
+ if result.get('capabilities'):
386
+ lines.append(f" Capabilities: {len(result['capabilities'])} feature(s)")
387
+
388
+ # Security features
389
+ lines.append(" Security Features:")
390
+ has_security = False
391
+
392
+ if result.get('supports_starttls'):
393
+ lines.append(" ✓ STARTTLS - Opportunistic TLS encryption supported")
394
+ has_security = True
395
+ elif not result.get('is_ssl_wrapped'):
396
+ lines.append(" ✗ STARTTLS - NOT supported (unencrypted connection)")
397
+
398
+ if result.get('is_ssl_wrapped'):
399
+ lines.append(" ✓ Implicit TLS - Connection is encrypted from start")
400
+ has_security = True
401
+
402
+ if result.get('supports_auth'):
403
+ lines.append(f" ✓ Authentication required")
404
+ if result.get('auth_methods'):
405
+ auth_methods = result['auth_methods']
406
+ lines.append(f" Methods: {', '.join(auth_methods)}")
407
+
408
+ # Security assessment of auth methods
409
+ for method in auth_methods:
410
+ method_upper = method.upper()
411
+ if method_upper == 'PLAIN':
412
+ if result.get('supports_starttls') or result.get('is_ssl_wrapped'):
413
+ lines.append(f" {method}: ⚠ Plaintext auth (safe over TLS)")
414
+ else:
415
+ lines.append(f" {method}: ✗ INSECURE (plaintext over unencrypted connection)")
416
+ elif method_upper == 'LOGIN':
417
+ if result.get('supports_starttls') or result.get('is_ssl_wrapped'):
418
+ lines.append(f" {method}: ⚠ Base64 encoded (safe over TLS)")
419
+ else:
420
+ lines.append(f" {method}: ✗ INSECURE (base64 over unencrypted connection)")
421
+ elif method_upper in ['CRAM-MD5', 'DIGEST-MD5']:
422
+ lines.append(f" {method}: ✓ Challenge-response (resistant to replay)")
423
+ elif method_upper in ['SCRAM-SHA-1', 'SCRAM-SHA-256']:
424
+ lines.append(f" {method}: ✓ Modern SCRAM authentication")
425
+ elif method_upper == 'XOAUTH2':
426
+ lines.append(f" {method}: ✓ OAuth 2.0 token authentication")
427
+ else:
428
+ if port == 25:
429
+ lines.append(" ⚠ No authentication (open relay risk if not restricted)")
430
+ else:
431
+ lines.append(" ✗ No authentication advertised")
432
+
433
+ if not has_security and not result.get('supports_starttls') and not result.get('is_ssl_wrapped'):
434
+ lines.append(" ✗ No encryption - traffic is sent in plaintext")
435
+
436
+ # Extended features
437
+ extended_features = []
438
+ if result.get('supports_pipelining'):
439
+ extended_features.append("PIPELINING (performance)")
440
+ if result.get('supports_8bitmime'):
441
+ extended_features.append("8BITMIME (international characters)")
442
+ if result.get('supports_smtputf8'):
443
+ extended_features.append("SMTPUTF8 (international email addresses)")
444
+ if result.get('supports_chunking'):
445
+ extended_features.append("CHUNKING (efficient large messages)")
446
+
447
+ if extended_features:
448
+ lines.append(" Extended Features:")
449
+ for feature in extended_features:
450
+ lines.append(f" • {feature}")
451
+
452
+ # Message size limit
453
+ if result.get('max_message_size'):
454
+ size_mb = result['max_message_size'] / (1024 * 1024)
455
+ lines.append(f" Max Message Size: {size_mb:.1f} MB ({result['max_message_size']:,} bytes)")
456
+
457
+ lines.append("")
458
+
459
+ # Overall security assessment across all MX servers
460
+ lines.append("=" * 70)
461
+ lines.append("Overall Email Infrastructure Security:")
462
+ lines.append("=" * 70)
463
+
464
+ # Check if any MX server has TLS and auth
465
+ any_has_tls = False
466
+ any_has_auth = False
467
+ all_have_tls = True
468
+ all_have_auth = True
469
+
470
+ for mx in mx_servers:
471
+ for result in mx.get('port_results', []):
472
+ if result.get('port_open'):
473
+ has_tls = result.get('supports_starttls') or result.get('is_ssl_wrapped')
474
+ has_auth = result.get('supports_auth')
475
+
476
+ if has_tls:
477
+ any_has_tls = True
478
+ else:
479
+ all_have_tls = False
480
+
481
+ if has_auth:
482
+ any_has_auth = True
483
+ else:
484
+ all_have_auth = False
485
+
486
+ if all_have_tls and any_has_auth:
487
+ lines.append(" ✓ Excellent: All mail servers support encryption and authentication")
488
+ elif any_has_tls and any_has_auth:
489
+ lines.append(" ✓ Good: Encryption and authentication available on some servers")
490
+ elif any_has_tls:
491
+ lines.append(" ⚠ Moderate: Encryption available but check authentication requirements")
492
+ elif any_has_auth:
493
+ lines.append(" ⚠ Weak: Authentication available but no encryption (credentials exposed)")
494
+ else:
495
+ lines.append(" ✗ Poor: No encryption or authentication detected on any MX server")
496
+
497
+ # Recommendations
498
+ lines.append("")
499
+ lines.append("Recommendations:")
500
+
501
+ if not all_have_tls:
502
+ lines.append(" • Enable STARTTLS on all MX servers for encrypted mail transport")
503
+
504
+ if not any_has_auth:
505
+ lines.append(" • Configure SMTP authentication on submission ports (587/465)")
506
+
507
+ # Check for multiple MX servers (redundancy)
508
+ if len(mx_with_open_ports) == 1:
509
+ lines.append(" • Consider adding backup MX servers for redundancy")
510
+ elif len(mx_with_open_ports) > 1:
511
+ lines.append(f" ✓ Good: {len(mx_with_open_ports)} MX servers provide redundancy")
512
+
513
+ if all_have_tls and any_has_auth and len(mx_with_open_ports) > 1:
514
+ lines.append(" ✓ Email infrastructure looks solid - continue monitoring")
515
+
516
+ lines.append("")
517
+ return '\n'.join(lines)