diff --git a/README.md b/README.md
index 1149016..92eda28 100644
--- a/README.md
+++ b/README.md
@@ -74,4 +74,4 @@ Options
## License
-This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details
+This project is licensed under the GNU General Public License v3 (GPL-3.0) - see the [LICENSE](LICENSE) file for details
diff --git a/moodlescan.py b/moodlescan.py
index e03abc1..619bcfb 100755
--- a/moodlescan.py
+++ b/moodlescan.py
@@ -1,6 +1,21 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
-
+#
+# moodlescan - Tool for scanning Moodle LMS platforms for vulnerabilities
+# Copyright (C) 2025 Victor Herrera - supported by www.incode.cl and CERT-POLSKA
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
import datetime
import hashlib
diff --git a/requirements.txt b/requirements.txt
index 663bd1f..e6479df 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1,2 @@
-requests
\ No newline at end of file
+requests>=2.31.0
+beautifulsoup4>=4.12.0
\ No newline at end of file
diff --git a/test_moodlescan.py b/test_moodlescan.py
index 6b74cc7..fa2a1be 100644
--- a/test_moodlescan.py
+++ b/test_moodlescan.py
@@ -1,4 +1,20 @@
#!/usr/bin/env python3
+#
+# moodlescan - Tool for scanning Moodle LMS platforms for vulnerabilities
+# Copyright (C) 2025 Victor Herrera - supported by www.incode.cl and CERT-POLSKA
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
import moodlescan
diff --git a/update/updater.py b/update/updater.py
new file mode 100755
index 0000000..a0c68d3
--- /dev/null
+++ b/update/updater.py
@@ -0,0 +1,316 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import hashlib
+import logging
+import tempfile
+import argparse
+from pathlib import Path
+from typing import Optional, List, Dict
+import requests
+import zipfile
+from datetime import datetime
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+# Configure logging
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(levelname)s - %(message)s',
+ handlers=[
+ logging.StreamHandler(),
+ logging.FileHandler('updater.log')
+ ]
+)
+logger = logging.getLogger(__name__)
+
+class DownloadError(Exception):
+ """Custom exception for download failures"""
+ pass
+
+class ExtractionError(Exception):
+ """Custom exception for extraction failures"""
+ pass
+
+class HashingError(Exception):
+ """Custom exception for hashing failures"""
+ pass
+
+class MoodleDownloader:
+ """Handles downloading of Moodle versions"""
+
+ DOWNLOAD_BASE = "https://download.moodle.org"
+ STABLE_PATH = "/download.php/stable{major_version_path}/moodle-{version}.{ext}"
+ DIRECT_PATH = "/download.php/direct/stable{major_version_path}/moodle-{version}.{ext}"
+ SHA_URL = f"{DOWNLOAD_BASE}/download.php/direct/stable{{major_version_path}}/moodle-{{version}}.{{ext}}.sha256"
+ VERSION_CHECK_URLS = [
+ f"{DOWNLOAD_BASE}/releases/latest/",
+ f"{DOWNLOAD_BASE}/releases/supported/",
+ f"{DOWNLOAD_BASE}/releases/security/",
+ f"{DOWNLOAD_BASE}/releases/legacy/"
+ ]
+
+ def __init__(self, version: str):
+ self.version = version
+ self.major_version = '.'.join(version.split('.')[:2])
+ # Convert 4.5.3 to 405 for the URL path
+ major_parts = self.major_version.split('.')
+ self.major_version_path = f"{major_parts[0]}0{major_parts[1]}"
+
+ def get_download_url(self, ext: str, direct: bool = False) -> str:
+ """Get the download URL for a specific format"""
+ path = self.DIRECT_PATH if direct else self.STABLE_PATH
+ return self.DOWNLOAD_BASE + path.format(
+ major_version_path=self.major_version_path,
+ version=self.version,
+ ext=ext
+ )
+
+ def verify_version_exists(self) -> bool:
+ """Verify if the Moodle version exists before attempting download"""
+ logger.info(f"Verifying Moodle version {self.version} exists")
+
+ # First check the version listings
+ try:
+ for check_url in self.VERSION_CHECK_URLS:
+ response = requests.get(check_url)
+ response.raise_for_status()
+ if f"Moodle {self.version}" in response.text:
+ logger.info(f"Found version {self.version} in listings")
+ return True
+ except requests.exceptions.RequestException:
+ pass
+
+ # Then try SHA files which should exist for valid versions
+ try:
+ for ext in ['zip', 'tgz']:
+ url = self.SHA_URL.format(
+ major_version_path=self.major_version_path,
+ version=self.version,
+ ext=ext
+ )
+ response = requests.head(url)
+ if response.status_code == 200:
+ logger.info(f"Found SHA file for version {self.version} ({ext})")
+ return True
+ except requests.exceptions.RequestException:
+ pass
+
+ logger.error(f"Moodle version {self.version} not found")
+ return False
+
+ def download_file(self, url: str, target_file: Path, timeout: int = 300) -> bool:
+ """Download a file with timeout and checks"""
+ try:
+ # First try direct download
+ response = requests.get(url, stream=True, timeout=timeout)
+ response.raise_for_status()
+
+ content_type = response.headers.get('content-type', '').lower()
+ if 'text/html' in content_type:
+ return False
+
+ if not any(t in content_type for t in ['zip', 'gzip', 'octet-stream', 'application/x-gzip']):
+ return False
+
+ logger.info(f"Downloading file from {url}")
+ with open(target_file, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ f.write(chunk)
+ return True
+
+ except requests.exceptions.RequestException:
+ return False
+
+ def download(self, target_dir: Path) -> Path:
+ """Download Moodle version to target directory"""
+ # First verify the version exists
+ if not self.verify_version_exists():
+ raise DownloadError(f"Moodle version {self.version} not found or not accessible")
+
+ # Try both zip and tgz formats
+ errors = []
+ for ext in ['zip', 'tgz']:
+ target_file = target_dir / f"moodle-{self.version}.{ext}"
+
+ # First try direct download URL
+ direct_url = self.get_download_url(ext, direct=True)
+ logger.info(f"Attempting direct download from {direct_url}")
+ if self.download_file(direct_url, target_file):
+ logger.info(f"Successfully downloaded to {target_file}")
+ return target_file
+
+ # If direct fails, try the regular download URL
+ regular_url = self.get_download_url(ext, direct=False)
+ logger.info(f"Attempting regular download from {regular_url}")
+ if self.download_file(regular_url, target_file):
+ logger.info(f"Successfully downloaded to {target_file}")
+ return target_file
+
+ errors.append(f"{ext}: Failed both direct and regular download attempts")
+
+ error_details = "\n".join(errors)
+ raise DownloadError(f"Failed to download Moodle {self.version} in any format:\n{error_details}")
+
+class MoodleExtractor:
+ """Handles extraction of Moodle archives"""
+
+ def extract(self, archive_path: Path, target_dir: Path) -> Path:
+ """Extract Moodle archive (ZIP or TGZ)"""
+ logger.info(f"Extracting {archive_path} to {target_dir}")
+
+ try:
+ if archive_path.suffix == '.zip':
+ with zipfile.ZipFile(archive_path, 'r') as zip_ref:
+ zip_ref.extractall(target_dir)
+ elif archive_path.suffix == '.tgz':
+ import tarfile
+ with tarfile.open(archive_path, 'r:gz') as tar_ref:
+ tar_ref.extractall(target_dir)
+ else:
+ raise ExtractionError(f"Unsupported archive format: {archive_path.suffix}")
+
+ # The extracted directory will be named 'moodle'
+ extracted_dir = target_dir / 'moodle'
+ if not extracted_dir.exists():
+ raise ExtractionError(f"Expected 'moodle' directory not found in extracted archive")
+
+ logger.info(f"Successfully extracted to {extracted_dir}")
+ return extracted_dir
+
+ except (zipfile.BadZipFile, tarfile.TarError) as e:
+ raise ExtractionError(f"Failed to extract corrupt archive: {str(e)}")
+ except Exception as e:
+ raise ExtractionError(f"Failed to extract archive: {str(e)}")
+
+class FileHasher:
+ """Handles file hashing operations"""
+
+ def __init__(self, algorithm: str = 'sha256'):
+ self.algorithm = algorithm
+
+ def compute_hash(self, file_path: Path) -> str:
+ """Compute hash for a single file"""
+ try:
+ hash_obj = hashlib.new(self.algorithm)
+ with open(file_path, 'rb') as f:
+ for chunk in iter(lambda: f.read(4096), b''):
+ hash_obj.update(chunk)
+ return hash_obj.hexdigest()
+ except Exception as e:
+ raise HashingError(f"Failed to hash {file_path}: {str(e)}")
+
+ def hash_directory(self, directory: Path) -> Dict[str, str]:
+ """Hash all files in directory recursively"""
+ results = {}
+
+ with ThreadPoolExecutor() as executor:
+ future_to_path = {}
+
+ for file_path in directory.rglob('*'):
+ if file_path.is_file():
+ future = executor.submit(self.compute_hash, file_path)
+ future_to_path[future] = file_path
+
+ for future in as_completed(future_to_path):
+ file_path = future_to_path[future]
+ try:
+ file_hash = future.result()
+ # Store relative path with forward slashes
+ relative_path = str(file_path.relative_to(directory)).replace(os.sep, '/')
+ results[relative_path] = file_hash
+ except Exception as e:
+ logger.warning(f"Failed to hash {file_path}: {str(e)}")
+
+ return results
+
+class VersionFileUpdater:
+ """Handles updating the version.txt file"""
+
+ def __init__(self, output_path: Path):
+ self.output_path = output_path
+
+ def update(self, version: str, hash_results: Dict[str, str]):
+ """Update version.txt with new entries"""
+ logger.info(f"Updating {self.output_path} with version {version}")
+
+ try:
+ with open(self.output_path, 'a') as f:
+ for file_path, file_hash in sorted(hash_results.items()):
+ entry = f"{version};{file_hash};/{file_path}\n"
+ f.write(entry)
+
+ logger.info(f"Successfully updated {self.output_path}")
+
+ except Exception as e:
+ logger.error(f"Failed to update {self.output_path}: {str(e)}")
+ raise
+
+class MoodleVersionUpdater:
+ """Main class orchestrating the update process"""
+
+ def __init__(self, version: str, output_path: Path):
+ self.version = version
+ self.output_path = output_path
+ self.downloader = MoodleDownloader(version)
+ self.extractor = MoodleExtractor()
+ self.hasher = FileHasher()
+ self.updater = VersionFileUpdater(output_path)
+
+ def update(self):
+ """Run the complete update process"""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ try:
+ # Download Moodle
+ zip_path = self.downloader.download(temp_path)
+
+ # Extract archive
+ moodle_dir = self.extractor.extract(zip_path, temp_path)
+
+ # Compute hashes
+ hash_results = self.hasher.hash_directory(moodle_dir)
+
+ # Update version.txt
+ self.updater.update(self.version, hash_results)
+
+ logger.info("Update process completed successfully")
+
+ except (DownloadError, ExtractionError, HashingError) as e:
+ logger.error(f"Update process failed: {str(e)}")
+ raise
+ except Exception as e:
+ logger.error(f"Unexpected error during update: {str(e)}")
+ raise
+
+def main():
+ parser = argparse.ArgumentParser(description="Update Moodle version.txt")
+ parser.add_argument('version', help="Moodle version number (e.g., 4.0.3)")
+ parser.add_argument(
+ '--output',
+ type=Path,
+ default=Path('version.txt'),
+ help="Output version.txt path"
+ )
+ parser.add_argument(
+ '--log-level',
+ choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
+ default='INFO',
+ help="Set logging level"
+ )
+
+ args = parser.parse_args()
+
+ # Update log level if specified
+ logging.getLogger().setLevel(args.log_level)
+
+ try:
+ updater = MoodleVersionUpdater(args.version, args.output)
+ updater.update()
+ except Exception as e:
+ logger.error(f"Failed to update version.txt: {str(e)}")
+ sys.exit(1)
+
+if __name__ == '__main__':
+ main()
\ No newline at end of file