anais-apk-forensic 1.0.0

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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +249 -0
  3. package/anais.sh +669 -0
  4. package/analysis_tools/__pycache__/apk_basic_info.cpython-313.pyc +0 -0
  5. package/analysis_tools/__pycache__/apk_basic_info.cpython-314.pyc +0 -0
  6. package/analysis_tools/__pycache__/check_zip_encryption.cpython-313.pyc +0 -0
  7. package/analysis_tools/__pycache__/check_zip_encryption.cpython-314.pyc +0 -0
  8. package/analysis_tools/__pycache__/detect_obfuscation.cpython-313.pyc +0 -0
  9. package/analysis_tools/__pycache__/detect_obfuscation.cpython-314.pyc +0 -0
  10. package/analysis_tools/__pycache__/dex_payload_hunter.cpython-314.pyc +0 -0
  11. package/analysis_tools/__pycache__/entropy_analyzer.cpython-314.pyc +0 -0
  12. package/analysis_tools/__pycache__/error_logger.cpython-313.pyc +0 -0
  13. package/analysis_tools/__pycache__/error_logger.cpython-314.pyc +0 -0
  14. package/analysis_tools/__pycache__/find_encrypted_payload.cpython-314.pyc +0 -0
  15. package/analysis_tools/__pycache__/fix_apk_headers.cpython-313.pyc +0 -0
  16. package/analysis_tools/__pycache__/fix_apk_headers.cpython-314.pyc +0 -0
  17. package/analysis_tools/__pycache__/manifest_analyzer.cpython-313.pyc +0 -0
  18. package/analysis_tools/__pycache__/manifest_analyzer.cpython-314.pyc +0 -0
  19. package/analysis_tools/__pycache__/network_analyzer.cpython-313.pyc +0 -0
  20. package/analysis_tools/__pycache__/network_analyzer.cpython-314.pyc +0 -0
  21. package/analysis_tools/__pycache__/report_generator.cpython-313.pyc +0 -0
  22. package/analysis_tools/__pycache__/report_generator.cpython-314.pyc +0 -0
  23. package/analysis_tools/__pycache__/report_generator_modular.cpython-314.pyc +0 -0
  24. package/analysis_tools/__pycache__/sast_scanner.cpython-313.pyc +0 -0
  25. package/analysis_tools/__pycache__/sast_scanner.cpython-314.pyc +0 -0
  26. package/analysis_tools/__pycache__/so_string_analyzer.cpython-314.pyc +0 -0
  27. package/analysis_tools/__pycache__/yara_enhanced_analyzer.cpython-314.pyc +0 -0
  28. package/analysis_tools/__pycache__/yara_results_processor.cpython-314.pyc +0 -0
  29. package/analysis_tools/apk_basic_info.py +85 -0
  30. package/analysis_tools/check_zip_encryption.py +142 -0
  31. package/analysis_tools/detect_obfuscation.py +650 -0
  32. package/analysis_tools/dex_payload_hunter.py +734 -0
  33. package/analysis_tools/entropy_analyzer.py +335 -0
  34. package/analysis_tools/error_logger.py +75 -0
  35. package/analysis_tools/find_encrypted_payload.py +485 -0
  36. package/analysis_tools/fix_apk_headers.py +154 -0
  37. package/analysis_tools/manifest_analyzer.py +214 -0
  38. package/analysis_tools/network_analyzer.py +287 -0
  39. package/analysis_tools/report_generator.py +506 -0
  40. package/analysis_tools/report_generator_modular.py +885 -0
  41. package/analysis_tools/sast_scanner.py +412 -0
  42. package/analysis_tools/so_string_analyzer.py +406 -0
  43. package/analysis_tools/yara_enhanced_analyzer.py +330 -0
  44. package/analysis_tools/yara_results_processor.py +368 -0
  45. package/analyzer_config.json +113 -0
  46. package/apkid/__init__.py +32 -0
  47. package/apkid/__pycache__/__init__.cpython-313.pyc +0 -0
  48. package/apkid/__pycache__/__init__.cpython-314.pyc +0 -0
  49. package/apkid/__pycache__/apkid.cpython-313.pyc +0 -0
  50. package/apkid/__pycache__/apkid.cpython-314.pyc +0 -0
  51. package/apkid/__pycache__/main.cpython-313.pyc +0 -0
  52. package/apkid/__pycache__/main.cpython-314.pyc +0 -0
  53. package/apkid/__pycache__/output.cpython-313.pyc +0 -0
  54. package/apkid/__pycache__/rules.cpython-313.pyc +0 -0
  55. package/apkid/apkid.py +266 -0
  56. package/apkid/main.py +98 -0
  57. package/apkid/output.py +177 -0
  58. package/apkid/rules/apk/common.yara +68 -0
  59. package/apkid/rules/apk/obfuscators.yara +118 -0
  60. package/apkid/rules/apk/packers.yara +1197 -0
  61. package/apkid/rules/apk/protectors.yara +301 -0
  62. package/apkid/rules/dex/abnormal.yara +104 -0
  63. package/apkid/rules/dex/anti-vm.yara +568 -0
  64. package/apkid/rules/dex/common.yara +60 -0
  65. package/apkid/rules/dex/compilers.yara +434 -0
  66. package/apkid/rules/dex/obfuscators.yara +602 -0
  67. package/apkid/rules/dex/packers.yara +761 -0
  68. package/apkid/rules/dex/protectors.yara +520 -0
  69. package/apkid/rules/dll/common.yara +38 -0
  70. package/apkid/rules/dll/obfuscators.yara +43 -0
  71. package/apkid/rules/elf/anti-vm.yara +43 -0
  72. package/apkid/rules/elf/common.yara +54 -0
  73. package/apkid/rules/elf/obfuscators.yara +991 -0
  74. package/apkid/rules/elf/packers.yara +1128 -0
  75. package/apkid/rules/elf/protectors.yara +794 -0
  76. package/apkid/rules/res/common.yara +43 -0
  77. package/apkid/rules/res/obfuscators.yara +46 -0
  78. package/apkid/rules/res/protectors.yara +46 -0
  79. package/apkid/rules.py +77 -0
  80. package/bin/anais +3 -0
  81. package/dist/cli.js +82 -0
  82. package/dist/index.js +123 -0
  83. package/dist/types/index.js +2 -0
  84. package/dist/utils/index.js +21 -0
  85. package/dist/utils/output.js +44 -0
  86. package/dist/utils/paths.js +107 -0
  87. package/docs/ARCHITECTURE.txt +353 -0
  88. package/docs/Workflow and Reference.md +445 -0
  89. package/package.json +70 -0
  90. package/rules/yara_general_rules.yar +323 -0
  91. package/scripts/dynamic_analysis_helper.sh +334 -0
  92. package/scripts/frida/dpt_dex_dumper.js +145 -0
  93. package/scripts/frida/frida_dex_dump.js +145 -0
  94. package/scripts/frida/frida_hooks.js +437 -0
  95. package/scripts/frida/frida_websocket_extractor.js +154 -0
  96. package/scripts/setup.sh +206 -0
  97. package/scripts/validate_framework.sh +224 -0
  98. package/src/cli.ts +91 -0
  99. package/src/index.ts +123 -0
  100. package/src/types/index.ts +44 -0
  101. package/src/utils/index.ts +6 -0
  102. package/src/utils/output.ts +50 -0
  103. package/src/utils/paths.ts +72 -0
  104. package/tsconfig.json +14 -0
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Native Library String Analyzer
4
+ Extracts and analyzes strings from .so files to find DEX/protection artifacts
5
+ """
6
+
7
+ import sys
8
+ import zipfile
9
+ import re
10
+ from pathlib import Path
11
+ from collections import defaultdict
12
+ import json
13
+ import argparse
14
+
15
+ class SOStringAnalyzer:
16
+ def __init__(self, verbose=False):
17
+ self.verbose = verbose
18
+ self.results = {}
19
+
20
+ def extract_strings(self, data, min_length=4, max_length=200):
21
+ """Extract printable ASCII strings from binary data"""
22
+ strings = []
23
+ current_string = []
24
+
25
+ for byte in data:
26
+ if 32 <= byte < 127: # Printable ASCII range
27
+ current_string.append(chr(byte))
28
+ else:
29
+ if len(current_string) >= min_length:
30
+ string = ''.join(current_string)
31
+ if len(string) <= max_length: # Filter out extremely long strings
32
+ strings.append(string)
33
+ current_string = []
34
+
35
+ # Don't forget the last string
36
+ if len(current_string) >= min_length:
37
+ string = ''.join(current_string)
38
+ if len(string) <= max_length:
39
+ strings.append(string)
40
+
41
+ return strings
42
+
43
+ def categorize_strings(self, strings):
44
+ """Categorize extracted strings by type and importance"""
45
+ categories = {
46
+ 'dex_files': [], # .dex file references
47
+ 'zip_files': [], # .zip file references
48
+ 'jar_files': [], # .jar file references
49
+ 'dat_files': [], # .dat file references
50
+ 'apk_files': [], # .apk file references
51
+ 'so_files': [], # .so file references
52
+ 'code_cache_paths': [], # code_cache directory refs
53
+ 'data_paths': [], # /data/data/ paths
54
+ 'app_paths': [], # app-specific paths
55
+ 'file_operations': [], # file I/O related strings
56
+ 'encryption_keywords': [], # crypto-related keywords
57
+ 'class_loaders': [], # ClassLoader references
58
+ 'jni_methods': [], # JNI method names
59
+ 'java_classes': [], # Java class references
60
+ 'shell_keywords': [], # Shell/unpack keywords
61
+ 'url_patterns': [], # URL-like patterns
62
+ 'base64_like': [], # Base64-encoded looking strings
63
+ 'hex_patterns': [], # Hexadecimal patterns
64
+ 'package_names': [], # Android package names
65
+ 'system_calls': [], # System call names
66
+ 'library_calls': [], # Library function calls
67
+ 'strings_of_interest': [] # Other suspicious strings
68
+ }
69
+
70
+ for string in strings:
71
+ string_lower = string.lower()
72
+
73
+ # File extensions (HIGH PRIORITY)
74
+ if '.dex' in string_lower:
75
+ categories['dex_files'].append(string)
76
+ if '.zip' in string_lower:
77
+ categories['zip_files'].append(string)
78
+ if '.jar' in string_lower:
79
+ categories['jar_files'].append(string)
80
+ if '.dat' in string_lower:
81
+ categories['dat_files'].append(string)
82
+ if '.apk' in string_lower:
83
+ categories['apk_files'].append(string)
84
+ if '.so' in string_lower and '/' in string:
85
+ categories['so_files'].append(string)
86
+
87
+ # Path patterns (HIGH PRIORITY)
88
+ if 'code_cache' in string_lower or 'code-cache' in string_lower:
89
+ categories['code_cache_paths'].append(string)
90
+ if '/data/data/' in string_lower or '/data/app/' in string_lower:
91
+ categories['data_paths'].append(string)
92
+ if any(p in string_lower for p in ['/files/', '/cache/', '/app_dex/', '/app_', '/databases/']):
93
+ categories['app_paths'].append(string)
94
+
95
+ # File operations
96
+ if any(op in string_lower for op in ['fopen', 'fread', 'fwrite', 'fclose', 'open64', 'read', 'write', 'mkdir', 'rmdir']):
97
+ if len(string) < 50: # Avoid long false positives
98
+ categories['file_operations'].append(string)
99
+
100
+ # Encryption keywords (HIGH PRIORITY)
101
+ if any(kw in string_lower for kw in ['encrypt', 'decrypt', 'cipher', 'aes', 'des', 'rsa', 'crypto', 'md5', 'sha', 'key', 'iv', 'salt']):
102
+ if len(string) < 80:
103
+ categories['encryption_keywords'].append(string)
104
+
105
+ # ClassLoader patterns (HIGH PRIORITY)
106
+ if any(cl in string_lower for cl in ['classloader', 'dexclassloader', 'pathclassloader', 'basedexclassloader', 'inmemorydexclassloader']):
107
+ categories['class_loaders'].append(string)
108
+
109
+ # JNI methods
110
+ if string.startswith('JNI_') or string.startswith('Java_'):
111
+ categories['jni_methods'].append(string)
112
+ if any(jni in string for jni in ['JNIEnv', 'jmethodID', 'jclass', 'jobject', 'jstring']):
113
+ categories['jni_methods'].append(string)
114
+
115
+ # Java class patterns (com.*, android.*, etc.)
116
+ if re.match(r'^[a-z]+(\.[a-z][a-z0-9_]*)+', string):
117
+ if '/' not in string and len(string) < 100:
118
+ categories['java_classes'].append(string)
119
+ if 'dalvik/system/' in string_lower or 'android/app/' in string_lower:
120
+ categories['java_classes'].append(string)
121
+
122
+ # Shell/Protection keywords (HIGH PRIORITY)
123
+ if any(sh in string_lower for sh in ['shell', 'unpack', 'stub', 'proxy', 'wrapper', 'loader', 'attach', 'detach', 'hook']):
124
+ if len(string) < 60:
125
+ categories['shell_keywords'].append(string)
126
+
127
+ # URL patterns
128
+ if re.match(r'https?://', string_lower):
129
+ categories['url_patterns'].append(string)
130
+
131
+ # Base64-like patterns (long alphanumeric strings)
132
+ if len(string) > 20 and re.match(r'^[A-Za-z0-9+/=]+$', string):
133
+ if len(string) < 150:
134
+ categories['base64_like'].append(string)
135
+
136
+ # Hex patterns
137
+ if len(string) > 16 and re.match(r'^[0-9a-fA-F]+$', string):
138
+ if len(string) < 100:
139
+ categories['hex_patterns'].append(string)
140
+
141
+ # Package name patterns
142
+ if re.match(r'^com\.[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$', string_lower):
143
+ categories['package_names'].append(string)
144
+
145
+ # System calls
146
+ if any(sc in string for sc in ['syscall', 'ptrace', 'mmap', 'mprotect', 'dlopen', 'dlsym', 'pthread']):
147
+ if len(string) < 50:
148
+ categories['system_calls'].append(string)
149
+
150
+ # Library calls
151
+ if any(lc in string for lc in ['libc.so', 'libdl.so', 'liblog.so', 'libm.so', 'libz.so']):
152
+ categories['library_calls'].append(string)
153
+
154
+ # Remove empty categories and remove duplicates
155
+ result = {}
156
+ for category, items in categories.items():
157
+ if items:
158
+ result[category] = list(set(items)) # Remove duplicates
159
+
160
+ return result
161
+
162
+ def analyze_so_file(self, so_path, data):
163
+ """Perform complete analysis on a single .so file"""
164
+ if self.verbose:
165
+ print(f"\n{'='*70}")
166
+ print(f"Analyzing: {so_path}")
167
+ print(f"Size: {len(data):,} bytes ({len(data)/1024:.2f} KB)")
168
+ print(f"{'='*70}")
169
+
170
+ # Extract strings
171
+ strings = self.extract_strings(data, min_length=4)
172
+
173
+ if self.verbose:
174
+ print(f"Extracted {len(strings)} strings")
175
+
176
+ # Categorize strings
177
+ categorized = self.categorize_strings(strings)
178
+
179
+ # Calculate statistics
180
+ stats = {
181
+ 'total_strings': len(strings),
182
+ 'categories_found': len(categorized),
183
+ 'file_size_bytes': len(data),
184
+ 'file_size_kb': len(data) / 1024
185
+ }
186
+
187
+ # Build result
188
+ result = {
189
+ 'file': so_path,
190
+ 'stats': stats,
191
+ 'categories': categorized
192
+ }
193
+
194
+ return result
195
+
196
+ def analyze_apk(self, apk_path, target_libs=None):
197
+ """Analyze .so files from APK"""
198
+ if not Path(apk_path).exists():
199
+ print(f"Error: APK not found: {apk_path}", file=sys.stderr)
200
+ return None
201
+
202
+ results = []
203
+
204
+ try:
205
+ with zipfile.ZipFile(apk_path, 'r') as zf:
206
+ # Find all .so files
207
+ so_files = [f for f in zf.filelist if f.filename.endswith('.so')]
208
+
209
+ # Filter by target if specified
210
+ if target_libs:
211
+ so_files = [f for f in so_files if any(target in f.filename.lower() for target in target_libs)]
212
+
213
+ if not so_files:
214
+ print("No .so files found in APK", file=sys.stderr)
215
+ return None
216
+
217
+ print(f"\nFound {len(so_files)} .so file(s) to analyze")
218
+
219
+ for so_file in so_files:
220
+ try:
221
+ data = zf.read(so_file.filename)
222
+ result = self.analyze_so_file(so_file.filename, data)
223
+ results.append(result)
224
+ except Exception as e:
225
+ print(f"Error analyzing {so_file.filename}: {e}", file=sys.stderr)
226
+
227
+ except Exception as e:
228
+ print(f"Error reading APK: {e}", file=sys.stderr)
229
+ return None
230
+
231
+ return results
232
+
233
+ def analyze_file(self, file_path):
234
+ """Analyze a single .so file"""
235
+ if not Path(file_path).exists():
236
+ print(f"Error: File not found: {file_path}", file=sys.stderr)
237
+ return None
238
+
239
+ try:
240
+ with open(file_path, 'rb') as f:
241
+ data = f.read()
242
+
243
+ result = self.analyze_so_file(str(file_path), data)
244
+ return [result]
245
+ except Exception as e:
246
+ print(f"Error reading file: {e}", file=sys.stderr)
247
+ return None
248
+
249
+ def print_results(self, results, show_all=False, limit=10):
250
+ """Print analysis results in a readable format"""
251
+ if not results:
252
+ print("No results to display")
253
+ return
254
+
255
+ for result in results:
256
+ print(f"\n{'='*70}")
257
+ print(f"šŸ“¦ {result['file']}")
258
+ print(f"{'='*70}")
259
+ print(f"Size: {result['stats']['file_size_kb']:.2f} KB")
260
+ print(f"Total strings extracted: {result['stats']['total_strings']}")
261
+ print(f"Categories found: {result['stats']['categories_found']}\n")
262
+
263
+ categories = result['categories']
264
+
265
+ # Priority categories first
266
+ priority_categories = [
267
+ ('dex_files', 'šŸŽÆ DEX Files', 'āš ļø CRITICAL'),
268
+ ('zip_files', 'šŸ”’ ZIP Files', 'āš ļø CRITICAL'),
269
+ ('jar_files', 'šŸ“¦ JAR Files', 'HIGH'),
270
+ ('dat_files', 'šŸ“„ DAT Files', 'HIGH'),
271
+ ('apk_files', 'šŸ“± APK Files', 'HIGH'),
272
+ ('code_cache_paths', 'šŸ“ Code Cache Paths', 'āš ļø CRITICAL'),
273
+ ('data_paths', 'šŸ“‚ Data Paths', 'HIGH'),
274
+ ('encryption_keywords', 'šŸ” Encryption Keywords', 'HIGH'),
275
+ ('class_loaders', 'šŸ”§ ClassLoaders', 'HIGH'),
276
+ ('shell_keywords', '🐚 Shell/Protection Keywords', 'HIGH'),
277
+ ]
278
+
279
+ # Show priority categories
280
+ for cat_key, cat_name, priority in priority_categories:
281
+ if cat_key in categories:
282
+ items = categories[cat_key]
283
+ print(f"\n{cat_name} [{priority}]: {len(items)} found")
284
+ display_limit = len(items) if show_all else min(limit, len(items))
285
+ for item in items[:display_limit]:
286
+ print(f" • {item}")
287
+ if len(items) > display_limit:
288
+ print(f" ... and {len(items) - display_limit} more (use --all to show)")
289
+
290
+ # Other categories
291
+ other_categories = [
292
+ ('jni_methods', 'āš™ļø JNI Methods'),
293
+ ('java_classes', 'ā˜• Java Classes'),
294
+ ('app_paths', 'šŸ“‚ App Paths'),
295
+ ('file_operations', 'šŸ“ File Operations'),
296
+ ('url_patterns', '🌐 URLs'),
297
+ ('package_names', 'šŸ“¦ Package Names'),
298
+ ('system_calls', '⚔ System Calls'),
299
+ ('so_files', 'šŸ“š SO Libraries'),
300
+ ('base64_like', 'šŸ”¤ Base64-like Strings'),
301
+ ('hex_patterns', 'šŸ”¢ Hex Patterns'),
302
+ ]
303
+
304
+ if show_all or self.verbose:
305
+ for cat_key, cat_name in other_categories:
306
+ if cat_key in categories:
307
+ items = categories[cat_key]
308
+ print(f"\n{cat_name}: {len(items)} found")
309
+ display_limit = len(items) if show_all else min(5, len(items))
310
+ for item in items[:display_limit]:
311
+ print(f" • {item}")
312
+ if len(items) > display_limit:
313
+ print(f" ... and {len(items) - display_limit} more")
314
+
315
+ def export_json(self, results, output_path):
316
+ """Export results to JSON file"""
317
+ try:
318
+ with open(output_path, 'w', encoding='utf-8') as f:
319
+ json.dump(results, f, indent=2, ensure_ascii=False)
320
+ print(f"\nāœ… Results exported to: {output_path}")
321
+ except Exception as e:
322
+ print(f"Error exporting JSON: {e}", file=sys.stderr)
323
+
324
+
325
+ def main():
326
+ parser = argparse.ArgumentParser(
327
+ description='Native Library String Analyzer - Extract and analyze strings from .so files',
328
+ formatter_class=argparse.RawDescriptionHelpFormatter,
329
+ epilog="""
330
+ Examples:
331
+ # Analyze all .so files in APK
332
+ python3 so_string_analyzer.py app.apk
333
+
334
+ # Analyze specific libraries (DPT-shell)
335
+ python3 so_string_analyzer.py app.apk --target libdpt
336
+
337
+ # Analyze a single .so file
338
+ python3 so_string_analyzer.py lib/arm64-v8a/libdpt.so
339
+
340
+ # Show all strings (no limits)
341
+ python3 so_string_analyzer.py app.apk --all
342
+
343
+ # Export to JSON
344
+ python3 so_string_analyzer.py app.apk --json results.json
345
+
346
+ # Verbose output
347
+ python3 so_string_analyzer.py app.apk -v
348
+ """
349
+ )
350
+
351
+ parser.add_argument('input', help='APK file or .so file to analyze')
352
+ parser.add_argument('--target', '-t', action='append', help='Target library name patterns (e.g., libdpt, libbangcle)')
353
+ parser.add_argument('--all', '-a', action='store_true', help='Show all strings (no limit)')
354
+ parser.add_argument('--limit', '-l', type=int, default=10, help='Limit items per category (default: 10)')
355
+ parser.add_argument('--json', '-j', help='Export results to JSON file')
356
+ parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
357
+ parser.add_argument('--brief', '-b', action='store_true', help='Brief output (critical findings only)')
358
+
359
+ args = parser.parse_args()
360
+
361
+ analyzer = SOStringAnalyzer(verbose=args.verbose)
362
+
363
+ # Determine input type
364
+ input_path = Path(args.input)
365
+
366
+ if not input_path.exists():
367
+ print(f"Error: File not found: {args.input}", file=sys.stderr)
368
+ sys.exit(1)
369
+
370
+ # Analyze
371
+ if input_path.suffix.lower() == '.apk':
372
+ results = analyzer.analyze_apk(str(input_path), target_libs=args.target)
373
+ elif input_path.suffix.lower() == '.so':
374
+ results = analyzer.analyze_file(str(input_path))
375
+ else:
376
+ # Try to analyze as .so file anyway
377
+ results = analyzer.analyze_file(str(input_path))
378
+
379
+ if not results:
380
+ print("Analysis failed", file=sys.stderr)
381
+ sys.exit(1)
382
+
383
+ # Print results
384
+ if not args.brief:
385
+ analyzer.print_results(results, show_all=args.all, limit=args.limit)
386
+ else:
387
+ # Brief output - only critical findings
388
+ for result in results:
389
+ print(f"\n{result['file']}:")
390
+ critical_cats = ['dex_files', 'zip_files', 'code_cache_paths', 'encryption_keywords', 'shell_keywords']
391
+ for cat in critical_cats:
392
+ if cat in result['categories']:
393
+ items = result['categories'][cat]
394
+ print(f" {cat}: {len(items)} found")
395
+ for item in items[:3]:
396
+ print(f" - {item}")
397
+
398
+ # Export JSON if requested
399
+ if args.json:
400
+ analyzer.export_json(results, args.json)
401
+
402
+ print(f"\nāœ… Analysis complete!")
403
+
404
+
405
+ if __name__ == '__main__':
406
+ main()