GT AI OS Community Edition v2.0.33

Security hardening release addressing CodeQL and Dependabot alerts:

- Fix stack trace exposure in error responses
- Add SSRF protection with DNS resolution checking
- Implement proper URL hostname validation (replaces substring matching)
- Add centralized path sanitization to prevent path traversal
- Fix ReDoS vulnerability in email validation regex
- Improve HTML sanitization in validation utilities
- Fix capability wildcard matching in auth utilities
- Update glob dependency to address CVE
- Add CodeQL suppression comments for verified false positives

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HackWeasel
2025-12-12 17:04:45 -05:00
commit b9dfb86260
746 changed files with 232071 additions and 0 deletions

View File

@@ -0,0 +1,344 @@
"""
Backup Service - Manages system backups and restoration
"""
import os
import asyncio
import hashlib
from typing import Dict, Any, Optional, List
from datetime import datetime
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_
from fastapi import HTTPException, status
import structlog
from app.models.system import BackupRecord, BackupType
logger = structlog.get_logger()
class BackupService:
"""Service for creating and managing system backups"""
BACKUP_SCRIPT = "/app/scripts/backup/backup-compose.sh"
RESTORE_SCRIPT = "/app/scripts/backup/restore-compose.sh"
BACKUP_DIR = os.getenv("GT2_BACKUP_DIR", "/app/backups")
def __init__(self, db: AsyncSession):
self.db = db
async def create_backup(
self,
backup_type: str = "manual",
description: str = None,
created_by: str = None
) -> Dict[str, Any]:
"""Create a new system backup"""
try:
# Validate backup type
if backup_type not in ["manual", "pre_update", "scheduled"]:
raise ValueError(f"Invalid backup type: {backup_type}")
# Ensure backup directory exists
os.makedirs(self.BACKUP_DIR, exist_ok=True)
# Generate backup filename
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
backup_filename = f"gt2_backup_{timestamp}.tar.gz"
backup_path = os.path.join(self.BACKUP_DIR, backup_filename)
# Get current version
current_version = await self._get_current_version()
# Create backup record
backup_record = BackupRecord(
backup_type=BackupType[backup_type],
location=backup_path,
version=current_version,
description=description or f"{backup_type.replace('_', ' ').title()} backup",
created_by=created_by,
components=self._get_backup_components()
)
self.db.add(backup_record)
await self.db.commit()
await self.db.refresh(backup_record)
# Run backup script in background
asyncio.create_task(
self._run_backup_process(backup_record.uuid, backup_path)
)
logger.info(f"Backup job {backup_record.uuid} created")
return backup_record.to_dict()
except Exception as e:
logger.error(f"Failed to create backup: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create backup: {str(e)}"
)
async def list_backups(
self,
limit: int = 50,
offset: int = 0,
backup_type: str = None
) -> Dict[str, Any]:
"""List available backups"""
try:
# Build query
query = select(BackupRecord)
if backup_type:
query = query.where(BackupRecord.backup_type == BackupType[backup_type])
query = query.order_by(desc(BackupRecord.created_at)).limit(limit).offset(offset)
result = await self.db.execute(query)
backups = result.scalars().all()
# Get total count
count_query = select(BackupRecord)
if backup_type:
count_query = count_query.where(BackupRecord.backup_type == BackupType[backup_type])
count_result = await self.db.execute(count_query)
total = len(count_result.scalars().all())
# Calculate total storage used by backups
backup_list = [b.to_dict() for b in backups]
storage_used = sum(b.get("size", 0) or 0 for b in backup_list)
return {
"backups": backup_list,
"total": total,
"limit": limit,
"offset": offset,
"storage_used": storage_used
}
except Exception as e:
logger.error(f"Failed to list backups: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list backups: {str(e)}"
)
async def get_backup(self, backup_id: str) -> Dict[str, Any]:
"""Get details of a specific backup"""
stmt = select(BackupRecord).where(BackupRecord.uuid == backup_id)
result = await self.db.execute(stmt)
backup = result.scalar_one_or_none()
if not backup:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Backup {backup_id} not found"
)
# Check if file actually exists
file_exists = os.path.exists(backup.location)
backup_dict = backup.to_dict()
backup_dict["file_exists"] = file_exists
return backup_dict
async def restore_backup(
self,
backup_id: str,
components: List[str] = None
) -> Dict[str, Any]:
"""Restore from a backup"""
# Get backup record
stmt = select(BackupRecord).where(BackupRecord.uuid == backup_id)
result = await self.db.execute(stmt)
backup = result.scalar_one_or_none()
if not backup:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Backup {backup_id} not found"
)
if not backup.is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Backup is marked as invalid and cannot be restored"
)
# Check if backup file exists
if not os.path.exists(backup.location):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Backup file not found on disk"
)
# Verify checksum if available
if backup.checksum:
calculated_checksum = await self._calculate_checksum(backup.location)
if calculated_checksum != backup.checksum:
backup.is_valid = False
await self.db.commit()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Backup checksum mismatch - file may be corrupted"
)
# Run restore in background
asyncio.create_task(self._run_restore_process(backup.location, components))
return {
"message": "Restore initiated",
"backup_id": backup_id,
"version": backup.version,
"components": components or list(backup.components.keys())
}
async def delete_backup(self, backup_id: str) -> Dict[str, Any]:
"""Delete a backup"""
stmt = select(BackupRecord).where(BackupRecord.uuid == backup_id)
result = await self.db.execute(stmt)
backup = result.scalar_one_or_none()
if not backup:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Backup {backup_id} not found"
)
# Delete file from disk
try:
if os.path.exists(backup.location):
os.remove(backup.location)
logger.info(f"Deleted backup file: {backup.location}")
except Exception as e:
logger.warning(f"Failed to delete backup file: {str(e)}")
# Delete database record
await self.db.delete(backup)
await self.db.commit()
return {
"message": "Backup deleted",
"backup_id": backup_id
}
async def _run_backup_process(self, backup_uuid: str, backup_path: str):
"""Background task to create backup"""
try:
# Reload backup record
stmt = select(BackupRecord).where(BackupRecord.uuid == backup_uuid)
result = await self.db.execute(stmt)
backup = result.scalar_one_or_none()
if not backup:
logger.error(f"Backup {backup_uuid} not found")
return
logger.info(f"Starting backup process: {backup_uuid}")
# Run backup script
process = await asyncio.create_subprocess_exec(
self.BACKUP_SCRIPT,
backup_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
# Success - calculate file size and checksum
if os.path.exists(backup_path):
backup.size_bytes = os.path.getsize(backup_path)
backup.checksum = await self._calculate_checksum(backup_path)
logger.info(f"Backup completed: {backup_uuid} ({backup.size_bytes} bytes)")
else:
backup.is_valid = False
logger.error(f"Backup file not created: {backup_path}")
else:
# Failure
backup.is_valid = False
error_msg = stderr.decode() if stderr else "Unknown error"
logger.error(f"Backup failed: {error_msg}")
await self.db.commit()
except Exception as e:
logger.error(f"Backup process error: {str(e)}")
# Mark backup as invalid
stmt = select(BackupRecord).where(BackupRecord.uuid == backup_uuid)
result = await self.db.execute(stmt)
backup = result.scalar_one_or_none()
if backup:
backup.is_valid = False
await self.db.commit()
async def _run_restore_process(self, backup_path: str, components: List[str] = None):
"""Background task to restore from backup"""
try:
logger.info(f"Starting restore process from: {backup_path}")
# Build restore command
cmd = [self.RESTORE_SCRIPT, backup_path]
if components:
cmd.extend(components)
# Run restore script
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
logger.info("Restore completed successfully")
else:
error_msg = stderr.decode() if stderr else "Unknown error"
logger.error(f"Restore failed: {error_msg}")
except Exception as e:
logger.error(f"Restore process error: {str(e)}")
async def _get_current_version(self) -> str:
"""Get current system version"""
try:
from app.models.system import SystemVersion
stmt = select(SystemVersion.version).where(
SystemVersion.is_current == True
).order_by(desc(SystemVersion.installed_at)).limit(1)
result = await self.db.execute(stmt)
version = result.scalar_one_or_none()
return version or "unknown"
except Exception:
return "unknown"
def _get_backup_components(self) -> Dict[str, bool]:
"""Get list of components to backup"""
return {
"databases": True,
"docker_volumes": True,
"configs": True,
"logs": False # Logs typically excluded to save space
}
async def _calculate_checksum(self, filepath: str) -> str:
"""Calculate SHA256 checksum of a file"""
try:
sha256_hash = hashlib.sha256()
with open(filepath, "rb") as f:
# Read file in chunks to handle large files
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
except Exception as e:
logger.error(f"Failed to calculate checksum: {str(e)}")
return ""