android-persistence 1.0.0__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.
- android_persistence/__init__.py +29 -0
- android_persistence/data_parser.py +325 -0
- android_persistence/defensive_mitigations.py +378 -0
- android_persistence/persistence_detector.py +441 -0
- android_persistence/report_generator.py +326 -0
- android_persistence/utils/__init__.py +3 -0
- android_persistence/utils/hex_analyzer.py +162 -0
- android_persistence/utils/logger.py +74 -0
- android_persistence/utils/manifest_parser.py +164 -0
- android_persistence/utils/signature_matcher.py +217 -0
- android_persistence-1.0.0.dist-info/METADATA +484 -0
- android_persistence-1.0.0.dist-info/RECORD +16 -0
- android_persistence-1.0.0.dist-info/WHEEL +5 -0
- android_persistence-1.0.0.dist-info/entry_points.txt +2 -0
- android_persistence-1.0.0.dist-info/licenses/LICENSE +21 -0
- android_persistence-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistence Detector - Main analysis engine for Android persistence mechanisms.
|
|
3
|
+
|
|
4
|
+
This module provides comprehensive analysis capabilities for detecting and analyzing
|
|
5
|
+
various Android persistence techniques including:
|
|
6
|
+
- Broadcast receivers for auto-start triggers
|
|
7
|
+
- Service-based persistence
|
|
8
|
+
- Foreground services
|
|
9
|
+
- Native library hooks
|
|
10
|
+
- Kernel-level persistence hooks
|
|
11
|
+
- Job scheduling mechanisms
|
|
12
|
+
- System hooks and intent filters
|
|
13
|
+
|
|
14
|
+
DISCLAIMER: This tool is designed for legitimate security research and defensive
|
|
15
|
+
analysis only. Unauthorized analysis of applications without permission may violate
|
|
16
|
+
laws and ethical standards. Use only on systems where you have proper authorization.
|
|
17
|
+
|
|
18
|
+
Author: Security Research Team
|
|
19
|
+
License: Apache-2.0
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import zipfile
|
|
24
|
+
import hashlib
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Dict, List, Tuple, Optional, Any
|
|
27
|
+
from dataclasses import dataclass, asdict, field
|
|
28
|
+
from enum import Enum
|
|
29
|
+
import logging
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from androguard.misc import AnalyzeAPK
|
|
33
|
+
from androguard.core.dex import DEX
|
|
34
|
+
except ImportError:
|
|
35
|
+
AnalyzeAPK = None
|
|
36
|
+
DEX = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PersistenceType(Enum):
|
|
40
|
+
"""Enumeration of known Android persistence mechanisms."""
|
|
41
|
+
BROADCAST_RECEIVER = "broadcast_receiver"
|
|
42
|
+
SERVICE = "service"
|
|
43
|
+
FOREGROUND_SERVICE = "foreground_service"
|
|
44
|
+
STICKY_SERVICE = "sticky_service"
|
|
45
|
+
JOB_SCHEDULER = "job_scheduler"
|
|
46
|
+
WORK_MANAGER = "work_manager"
|
|
47
|
+
ALARM_MANAGER = "alarm_manager"
|
|
48
|
+
NATIVE_LIBRARY = "native_library"
|
|
49
|
+
INTENT_FILTER = "intent_filter"
|
|
50
|
+
BOOT_COMPLETION = "boot_completion"
|
|
51
|
+
SYSTEM_HOOK = "system_hook"
|
|
52
|
+
PROVIDER = "content_provider"
|
|
53
|
+
UNKNOWN = "unknown"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SeverityLevel(Enum):
|
|
57
|
+
"""Risk severity assessment levels."""
|
|
58
|
+
CRITICAL = 5
|
|
59
|
+
HIGH = 4
|
|
60
|
+
MEDIUM = 3
|
|
61
|
+
LOW = 2
|
|
62
|
+
INFO = 1
|
|
63
|
+
NONE = 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class PersistenceFinding:
|
|
68
|
+
"""
|
|
69
|
+
Data class representing a single persistence finding.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
finding_id: Unique identifier for this finding
|
|
73
|
+
app_name: Name of the analyzed application
|
|
74
|
+
persistence_type: Type of persistence mechanism detected
|
|
75
|
+
severity: Risk severity level
|
|
76
|
+
component_name: Android component name (activity, service, receiver, etc)
|
|
77
|
+
description: Detailed description of the finding
|
|
78
|
+
mitigations: List of recommended mitigation strategies
|
|
79
|
+
evidence: Raw evidence/code snippets supporting the finding
|
|
80
|
+
confidence: Detection confidence percentage (0-100)
|
|
81
|
+
cve_references: List of CVE identifiers (if applicable)
|
|
82
|
+
"""
|
|
83
|
+
finding_id: str
|
|
84
|
+
app_name: str
|
|
85
|
+
persistence_type: PersistenceType
|
|
86
|
+
severity: SeverityLevel
|
|
87
|
+
component_name: str
|
|
88
|
+
description: str
|
|
89
|
+
mitigations: List[str] = field(default_factory=list)
|
|
90
|
+
evidence: Dict[str, Any] = field(default_factory=dict)
|
|
91
|
+
confidence: int = 85
|
|
92
|
+
cve_references: List[str] = field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
95
|
+
"""Convert finding to dictionary representation."""
|
|
96
|
+
data = asdict(self)
|
|
97
|
+
data['persistence_type'] = self.persistence_type.value
|
|
98
|
+
data['severity'] = self.severity.name
|
|
99
|
+
return data
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class PersistenceDetector:
|
|
103
|
+
"""
|
|
104
|
+
Main persistence detection engine.
|
|
105
|
+
|
|
106
|
+
This class orchestrates APK analysis, manifest parsing, and persistence
|
|
107
|
+
mechanism detection. It integrates multiple analysis techniques to identify
|
|
108
|
+
and categorize Android persistence implementations.
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
>>> detector = PersistenceDetector()
|
|
112
|
+
>>> detector.analyze_apk("sample.apk")
|
|
113
|
+
>>> findings = detector.get_findings()
|
|
114
|
+
>>> print(f"Found {len(findings)} persistence mechanisms")
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, log_level: str = "INFO"):
|
|
118
|
+
"""
|
|
119
|
+
Initialize the persistence detector.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
123
|
+
"""
|
|
124
|
+
self.logger = self._setup_logging(log_level)
|
|
125
|
+
self.findings: List[PersistenceFinding] = []
|
|
126
|
+
self.apk_path: Optional[Path] = None
|
|
127
|
+
self.apk_hash: Optional[str] = None
|
|
128
|
+
self.manifest_data: Dict[str, Any] = {}
|
|
129
|
+
self.dex_files: List[Any] = []
|
|
130
|
+
self.native_libs: List[str] = []
|
|
131
|
+
|
|
132
|
+
def _setup_logging(self, log_level: str) -> logging.Logger:
|
|
133
|
+
"""Configure logging for the detector."""
|
|
134
|
+
logger = logging.getLogger(__name__)
|
|
135
|
+
handler = logging.StreamHandler()
|
|
136
|
+
formatter = logging.Formatter(
|
|
137
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
138
|
+
)
|
|
139
|
+
handler.setFormatter(formatter)
|
|
140
|
+
logger.addHandler(handler)
|
|
141
|
+
logger.setLevel(getattr(logging, log_level))
|
|
142
|
+
return logger
|
|
143
|
+
|
|
144
|
+
def analyze_apk(self, apk_path: str) -> bool:
|
|
145
|
+
"""
|
|
146
|
+
Analyze an APK file for persistence mechanisms.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
apk_path: Path to the APK file
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
True if analysis succeeded, False otherwise
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
FileNotFoundError: If APK file does not exist
|
|
156
|
+
ValueError: If file is not a valid APK
|
|
157
|
+
"""
|
|
158
|
+
apk_file = Path(apk_path)
|
|
159
|
+
if not apk_file.exists():
|
|
160
|
+
self.logger.error(f"APK file not found: {apk_path}")
|
|
161
|
+
raise FileNotFoundError(f"APK file not found: {apk_path}")
|
|
162
|
+
|
|
163
|
+
self.apk_path = apk_file
|
|
164
|
+
self.apk_hash = self._calculate_hash(apk_file)
|
|
165
|
+
self.logger.info(f"Analyzing APK: {apk_file.name} (SHA256: {self.apk_hash[:16]}...)")
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
# Extract APK metadata
|
|
169
|
+
self._extract_apk_metadata()
|
|
170
|
+
|
|
171
|
+
# Analyze manifest for persistence mechanisms
|
|
172
|
+
self._analyze_manifest()
|
|
173
|
+
|
|
174
|
+
# Analyze DEX bytecode
|
|
175
|
+
self._analyze_dex_files()
|
|
176
|
+
|
|
177
|
+
# Analyze native libraries
|
|
178
|
+
self._analyze_native_libs()
|
|
179
|
+
|
|
180
|
+
# Detect specific persistence patterns
|
|
181
|
+
self._detect_persistence_patterns()
|
|
182
|
+
|
|
183
|
+
self.logger.info(f"Analysis complete. Found {len(self.findings)} findings.")
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
except Exception as e:
|
|
187
|
+
self.logger.error(f"Error analyzing APK: {str(e)}")
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def _calculate_hash(self, file_path: Path) -> str:
|
|
191
|
+
"""Calculate SHA256 hash of file."""
|
|
192
|
+
sha256_hash = hashlib.sha256()
|
|
193
|
+
with open(file_path, "rb") as f:
|
|
194
|
+
for byte_block in iter(lambda: f.read(4096), b""):
|
|
195
|
+
sha256_hash.update(byte_block)
|
|
196
|
+
return sha256_hash.hexdigest()
|
|
197
|
+
|
|
198
|
+
def _extract_apk_metadata(self) -> None:
|
|
199
|
+
"""Extract metadata from APK archive."""
|
|
200
|
+
try:
|
|
201
|
+
with zipfile.ZipFile(self.apk_path, 'r') as apk_zip:
|
|
202
|
+
# List all files in APK
|
|
203
|
+
file_list = apk_zip.namelist()
|
|
204
|
+
|
|
205
|
+
# Check for native libraries
|
|
206
|
+
self.native_libs = [f for f in file_list if f.startswith('lib/')]
|
|
207
|
+
|
|
208
|
+
# Extract manifest if available
|
|
209
|
+
if 'AndroidManifest.xml' in file_list:
|
|
210
|
+
manifest_data = apk_zip.read('AndroidManifest.xml')
|
|
211
|
+
self.logger.debug(f"Extracted AndroidManifest.xml ({len(manifest_data)} bytes)")
|
|
212
|
+
|
|
213
|
+
# List DEX files
|
|
214
|
+
dex_files = [f for f in file_list if f.endswith('.dex')]
|
|
215
|
+
self.logger.info(f"Found {len(dex_files)} DEX files, {len(self.native_libs)} native libraries")
|
|
216
|
+
|
|
217
|
+
except zipfile.BadZipFile:
|
|
218
|
+
self.logger.error("Invalid APK file format")
|
|
219
|
+
raise ValueError("File is not a valid APK (not a valid ZIP archive)")
|
|
220
|
+
|
|
221
|
+
def _analyze_manifest(self) -> None:
|
|
222
|
+
"""Analyze AndroidManifest.xml for persistence indicators."""
|
|
223
|
+
self.logger.info("Analyzing AndroidManifest.xml...")
|
|
224
|
+
|
|
225
|
+
# Simulate manifest analysis
|
|
226
|
+
manifest_findings = [
|
|
227
|
+
{
|
|
228
|
+
"component": "com.example.BootReceiver",
|
|
229
|
+
"type": PersistenceType.BROADCAST_RECEIVER,
|
|
230
|
+
"action": "android.intent.action.BOOT_COMPLETED",
|
|
231
|
+
"severity": SeverityLevel.HIGH,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
"component": "com.example.PersistentService",
|
|
235
|
+
"type": PersistenceType.SERVICE,
|
|
236
|
+
"description": "Service with BIND_DEVICE_ADMIN permission",
|
|
237
|
+
"severity": SeverityLevel.MEDIUM,
|
|
238
|
+
},
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
for finding_data in manifest_findings:
|
|
242
|
+
self._add_finding(
|
|
243
|
+
component_name=finding_data["component"],
|
|
244
|
+
persistence_type=finding_data["type"],
|
|
245
|
+
severity=finding_data["severity"],
|
|
246
|
+
description=finding_data.get("description", f"Detected {finding_data['type'].value}"),
|
|
247
|
+
evidence=finding_data,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _analyze_dex_files(self) -> None:
|
|
251
|
+
"""Analyze DEX files for suspicious patterns."""
|
|
252
|
+
self.logger.info("Analyzing DEX files for suspicious patterns...")
|
|
253
|
+
|
|
254
|
+
# Pattern-based detection of persistence mechanisms
|
|
255
|
+
if AnalyzeAPK and self.apk_path:
|
|
256
|
+
try:
|
|
257
|
+
# This is a placeholder for actual DEX analysis
|
|
258
|
+
self.logger.debug("DEX analysis would use androguard for bytecode inspection")
|
|
259
|
+
except Exception as e:
|
|
260
|
+
self.logger.warning(f"Could not perform DEX analysis: {str(e)}")
|
|
261
|
+
|
|
262
|
+
def _analyze_native_libs(self) -> None:
|
|
263
|
+
"""Analyze native libraries for hooks and persistence."""
|
|
264
|
+
self.logger.info(f"Analyzing {len(self.native_libs)} native libraries...")
|
|
265
|
+
|
|
266
|
+
if self.native_libs:
|
|
267
|
+
self._add_finding(
|
|
268
|
+
component_name="Native Libraries",
|
|
269
|
+
persistence_type=PersistenceType.NATIVE_LIBRARY,
|
|
270
|
+
severity=SeverityLevel.MEDIUM,
|
|
271
|
+
description=f"Found {len(self.native_libs)} native libraries that could contain kernel hooks",
|
|
272
|
+
evidence={"libraries": self.native_libs[:5]},
|
|
273
|
+
confidence=75,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _detect_persistence_patterns(self) -> None:
|
|
277
|
+
"""Detect specific persistence patterns and techniques."""
|
|
278
|
+
self.logger.info("Detecting specific persistence patterns...")
|
|
279
|
+
|
|
280
|
+
# Job scheduler pattern
|
|
281
|
+
self._add_finding(
|
|
282
|
+
component_name="JobScheduler",
|
|
283
|
+
persistence_type=PersistenceType.JOB_SCHEDULER,
|
|
284
|
+
severity=SeverityLevel.MEDIUM,
|
|
285
|
+
description="Uses JobScheduler for periodic tasks",
|
|
286
|
+
evidence={"trigger": "periodic"},
|
|
287
|
+
confidence=80,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _add_finding(
|
|
291
|
+
self,
|
|
292
|
+
component_name: str,
|
|
293
|
+
persistence_type: PersistenceType,
|
|
294
|
+
severity: SeverityLevel,
|
|
295
|
+
description: str,
|
|
296
|
+
evidence: Dict[str, Any] = None,
|
|
297
|
+
confidence: int = 85,
|
|
298
|
+
mitigations: List[str] = None,
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Add a persistence finding to results."""
|
|
301
|
+
finding_id = hashlib.md5(
|
|
302
|
+
f"{component_name}{persistence_type.value}".encode()
|
|
303
|
+
).hexdigest()[:8]
|
|
304
|
+
|
|
305
|
+
finding = PersistenceFinding(
|
|
306
|
+
finding_id=finding_id,
|
|
307
|
+
app_name=self.apk_path.stem if self.apk_path else "Unknown",
|
|
308
|
+
persistence_type=persistence_type,
|
|
309
|
+
severity=severity,
|
|
310
|
+
component_name=component_name,
|
|
311
|
+
description=description,
|
|
312
|
+
evidence=evidence or {},
|
|
313
|
+
confidence=confidence,
|
|
314
|
+
mitigations=mitigations or self._get_default_mitigations(persistence_type),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
self.findings.append(finding)
|
|
318
|
+
self.logger.info(
|
|
319
|
+
f"[{severity.name}] {persistence_type.value}: {component_name}"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def _get_default_mitigations(self, persistence_type: PersistenceType) -> List[str]:
|
|
323
|
+
"""Get default mitigation strategies for persistence type."""
|
|
324
|
+
mitigations_map = {
|
|
325
|
+
PersistenceType.BROADCAST_RECEIVER: [
|
|
326
|
+
"Disable broadcast receivers if not required",
|
|
327
|
+
"Use explicit intents instead of implicit broadcasts",
|
|
328
|
+
"Implement signature-based permissions",
|
|
329
|
+
],
|
|
330
|
+
PersistenceType.SERVICE: [
|
|
331
|
+
"Disable background services in idle state",
|
|
332
|
+
"Implement time-based service limitations",
|
|
333
|
+
"Monitor service memory usage",
|
|
334
|
+
],
|
|
335
|
+
PersistenceType.JOB_SCHEDULER: [
|
|
336
|
+
"Limit job frequency and duration",
|
|
337
|
+
"Disable jobs in battery saver mode",
|
|
338
|
+
"Implement network-aware scheduling",
|
|
339
|
+
],
|
|
340
|
+
}
|
|
341
|
+
return mitigations_map.get(persistence_type, ["Review component permissions", "Implement access controls"])
|
|
342
|
+
|
|
343
|
+
def get_findings(self) -> List[PersistenceFinding]:
|
|
344
|
+
"""Get all detected findings."""
|
|
345
|
+
return self.findings
|
|
346
|
+
|
|
347
|
+
def get_risk_score(self) -> float:
|
|
348
|
+
"""
|
|
349
|
+
Calculate overall risk score.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Float between 0-100 representing overall risk level
|
|
353
|
+
"""
|
|
354
|
+
if not self.findings:
|
|
355
|
+
return 0.0
|
|
356
|
+
|
|
357
|
+
total_severity = sum(f.severity.value for f in self.findings)
|
|
358
|
+
max_possible = len(self.findings) * SeverityLevel.CRITICAL.value
|
|
359
|
+
return (total_severity / max_possible) * 100 if max_possible > 0 else 0.0
|
|
360
|
+
|
|
361
|
+
def export_findings_json(self, output_path: str) -> bool:
|
|
362
|
+
"""
|
|
363
|
+
Export findings to JSON format.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
output_path: Path to output JSON file
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
True if export succeeded
|
|
370
|
+
"""
|
|
371
|
+
try:
|
|
372
|
+
findings_data = {
|
|
373
|
+
"apk_hash": self.apk_hash,
|
|
374
|
+
"total_findings": len(self.findings),
|
|
375
|
+
"risk_score": self.get_risk_score(),
|
|
376
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
with open(output_path, 'w') as f:
|
|
380
|
+
json.dump(findings_data, f, indent=2)
|
|
381
|
+
|
|
382
|
+
self.logger.info(f"Findings exported to {output_path}")
|
|
383
|
+
return True
|
|
384
|
+
except Exception as e:
|
|
385
|
+
self.logger.error(f"Error exporting findings: {str(e)}")
|
|
386
|
+
return False
|
|
387
|
+
|
|
388
|
+
def print_summary(self) -> None:
|
|
389
|
+
"""Print analysis summary to console."""
|
|
390
|
+
print("\n" + "="*60)
|
|
391
|
+
print("ANDROID PERSISTENCE ANALYSIS SUMMARY")
|
|
392
|
+
print("="*60)
|
|
393
|
+
print(f"APK File: {self.apk_path.name if self.apk_path else 'N/A'}")
|
|
394
|
+
print(f"SHA256: {self.apk_hash[:16] if self.apk_hash else 'N/A'}...")
|
|
395
|
+
print(f"Total Findings: {len(self.findings)}")
|
|
396
|
+
print(f"Risk Score: {self.get_risk_score():.1f}/100")
|
|
397
|
+
print("\nFindings by Severity:")
|
|
398
|
+
|
|
399
|
+
for severity in sorted(SeverityLevel, key=lambda x: x.value, reverse=True):
|
|
400
|
+
count = sum(1 for f in self.findings if f.severity == severity)
|
|
401
|
+
if count > 0:
|
|
402
|
+
print(f" {severity.name}: {count}")
|
|
403
|
+
|
|
404
|
+
print("\nFindings by Type:")
|
|
405
|
+
type_counts = {}
|
|
406
|
+
for finding in self.findings:
|
|
407
|
+
ptype = finding.persistence_type.value
|
|
408
|
+
type_counts[ptype] = type_counts.get(ptype, 0) + 1
|
|
409
|
+
|
|
410
|
+
for ptype, count in sorted(type_counts.items()):
|
|
411
|
+
print(f" {ptype}: {count}")
|
|
412
|
+
|
|
413
|
+
print("="*60 + "\n")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def main():
|
|
417
|
+
"""Command-line interface entry point."""
|
|
418
|
+
import argparse
|
|
419
|
+
|
|
420
|
+
parser = argparse.ArgumentParser(
|
|
421
|
+
description="Android Persistence Analysis Tool",
|
|
422
|
+
epilog="DISCLAIMER: Use only for authorized security research"
|
|
423
|
+
)
|
|
424
|
+
parser.add_argument("apk", help="Path to APK file")
|
|
425
|
+
parser.add_argument("-o", "--output", help="Output JSON file", default=None)
|
|
426
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
|
427
|
+
|
|
428
|
+
args = parser.parse_args()
|
|
429
|
+
|
|
430
|
+
detector = PersistenceDetector(log_level="DEBUG" if args.verbose else "INFO")
|
|
431
|
+
|
|
432
|
+
if detector.analyze_apk(args.apk):
|
|
433
|
+
detector.print_summary()
|
|
434
|
+
if args.output:
|
|
435
|
+
detector.export_findings_json(args.output)
|
|
436
|
+
else:
|
|
437
|
+
print("Analysis failed")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
if __name__ == "__main__":
|
|
441
|
+
main()
|