"""SecurityAgent - Security monitoring for VoIdea. Scans code for: - XSS, SQLi, CSRF, SSTI, path traversal, command injection - Hardcoded secrets, CSP issues, .env exposure, rate limit bypass - Dependency vulnerabilities (pip-audit) - 152-FZ compliance - LogEntry analysis (last 24h) """ import json import re import subprocess from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent from app.core.config import get_settings from app.models.log import LogEntry settings = get_settings() class SecurityAgent(BaseAgent): """Security monitoring and vulnerability scanning agent.""" name = "security_agent" version = "1.0.0" description = "Monitors security, scans vulnerabilities, enforces 152-FZ compliance" triggers = [ AgentTrigger.MANUAL, AgentTrigger.PRE_COMMIT, AgentTrigger.CRON, ] def __init__(self): super().__init__() self.project_root = Path(__file__).parent.parent.parent def _get_db(self, context: dict[str, Any] | None = None) -> AsyncSession | None: return (context or {}).get("db", None) async def run(self, context: dict[str, Any] | None = None) -> AgentResult: """Execute security task. Context can contain: - action: str (full, scan, dependencies, compliance, logs) - paths: list[str] (paths to scan) - db: AsyncSession (for log analysis) """ await self.set_running("security_check") try: action = context.get("action", "full") if context else "full" paths = context.get("paths", ["app"]) db = context.get("db", None) if action == "full": result = await self._run_full_security_check(paths, db) elif action == "scan": result = await self._scan_vulnerabilities(paths) elif action == "dependencies": result = await self._check_dependencies() elif action == "compliance": result = await self._check_152_fz_compliance() elif action == "logs": result = await self._analyze_security_logs(db) else: result = await self._run_full_security_check(paths, db) await self.set_idle() return result except Exception as e: await self.set_error(str(e)) return AgentResult( success=False, message=f"SecurityAgent failed: {str(e)}", errors=[str(e)], ) async def health_check(self) -> bool: """Check if SecurityAgent is operational.""" return self.project_root.exists() async def _run_full_security_check(self, paths: list[str], db: AsyncSession | None = None) -> AgentResult: """Run complete security audit.""" vuln_result = await self._scan_vulnerabilities(paths) dep_result = await self._check_dependencies() compliance_result = await self._check_152_fz_compliance() log_result = await self._analyze_security_logs(db) critical_issues = [] critical_issues.extend(vuln_result.data.get("critical", [])) critical_issues.extend(dep_result.data.get("critical", [])) log_alerts = log_result.data.get("alerts", []) passed = len(critical_issues) == 0 and compliance_result.success and len(log_alerts) == 0 return AgentResult( success=passed, message=f"Security check {'passed' if passed else 'failed'}: {len(critical_issues)} critical issues, {len(log_alerts)} log alerts", data={ "vulnerabilities": vuln_result.data, "dependencies": dep_result.data, "compliance": compliance_result.data, "log_analysis": log_result.data, "total_critical": len(critical_issues), }, ) async def _scan_vulnerabilities(self, paths: list[str]) -> AgentResult: """Scan code for common vulnerabilities.""" issues = {"critical": [], "high": [], "medium": [], "low": []} patterns = { "critical": [ (r"eval\s*\(", "Dangerous use of eval()"), (r"os\.system\s*\(", "Dangerous use of os.system()"), (r"subprocess\.call\s*.*shell\s*=\s*True", "Shell injection vulnerability"), (r"exec\s*\(", "Dangerous use of exec()"), (r"__import__\s*\(", "Dynamic import detected"), (r"pickle\.loads\s*\(", "Insecure deserialization (pickle)"), (r"sqlalchemy\.text\s*\([^)]*\+", "Raw SQL concatenation, possible SQLi"), (r"\.execute\s*\([^)]*f\s*['\"]", "SQL injection risk in execute()"), ], "high": [ (r"password\s*=\s*['\"][^'\"]{1,8}['\"]", "Hardcoded password detected"), (r"api[_-]?key\s*=\s*['\"][A-Za-z0-9]{20,}['\"]", "Potential API key in code"), (r"secret[_-]?key\s*=\s*['\"][^'\"]{8,}['\"]", "Possible secret key in code"), (r"token\s*=\s*['\"][A-Za-z0-9_-]{20,}['\"]", "Hardcoded token detected"), (r"exec_command|exec_cmd", "Arbitrary command execution pattern"), (r"request\.remote_addr|request\.environ", "IP address exposure"), (r"render_template_string\s*\(", "SSTI (Server-Side Template Injection) risk"), (r"csrf_exempt|@csrf\.exempt", "CSRF protection disabled"), ], "medium": [ (r"\.format\s*\([^)]*\.\s*(password|token|secret)", "String formatting with secrets"), (r"print\s*\([^)]*password", "Password being printed"), (r"\.env\s*released|\.env\s*exposed", "Environment file exposure"), (r"open\s*\([^)]*\.\./", "Path traversal risk"), (r"mark_safe|autoescape\s*False|autoescape\s*off", "CSP/XSS risk"), (r"RateLimiter|rate_limit", "Rate limit bypass pattern"), (r"secure=False|ssl_require=False", "SSL/TLS disabled"), ], "low": [ (r"passlib", "Consider using more secure hashing"), (r"debug\s*=\s*True", "Debug mode enabled"), (r"ALLOWED_HOSTS\s*=\s*\[.+\]", "Overly permissive ALLOWED_HOSTS"), (r"CORS_ORIGIN_ALLOW_ALL\s*=\s*True", "CORS allows all origins"), ], } for path in paths: path_obj = self.project_root / path if not path_obj.exists(): continue for file in path_obj.rglob("*.py"): if file.name.startswith("test_"): continue try: content = file.read_text(encoding="utf-8", errors="ignore") for level, pattern_list in patterns.items(): for pattern, description in pattern_list: if re.search(pattern, content, re.IGNORECASE): issues[level].append({ "file": str(file.relative_to(self.project_root)), "description": description, "pattern": pattern, }) except Exception: pass has_critical = len(issues["critical"]) > 0 return AgentResult( success=not has_critical, message=f"Vulnerability scan: {sum(len(v) for v in issues.values())} issues", data=issues, ) async def _check_dependencies(self) -> AgentResult: """Check dependencies for known vulnerabilities.""" issues = {"critical": [], "high": [], "medium": [], "low": []} try: result = subprocess.run( ["pip", "audit", "--format=json"], capture_output=True, text=True, cwd=str(self.project_root), timeout=60, ) if result.returncode == 0: return AgentResult( success=True, message="No vulnerable dependencies", data=issues, ) try: import json audit_data = json.loads(result.stdout) for vuln in audit_data.get("vulnerabilities", []): severity = vuln.get("vulns", [{}])[0].get("advisory_severity", "medium") if severity not in issues: severity = "medium" issues[severity].append({ "package": vuln.get("name"), "version": vuln.get("version"), "advisory": vuln.get("advisory_id"), }) except (json.JSONDecodeError, KeyError): pass except FileNotFoundError: return AgentResult( success=True, message="pip-audit not installed, skipping dependency check", data={"skipped": True}, ) except subprocess.TimeoutExpired: return AgentResult( success=False, message="Dependency check timed out", data={"error": "timeout"}, ) except Exception: return AgentResult( success=True, message="Could not run dependency check", data={"skipped": True}, ) has_critical = len(issues["critical"]) > 0 or len(issues["high"]) > 0 return AgentResult( success=not has_critical, message=f"Dependency check: {sum(len(v) for v in issues.values())} issues", data=issues, ) async def _check_152_fz_compliance(self) -> AgentResult: """Check compliance with 152-FZ (personal data protection).""" issues = [] warnings = [] check_items = [ { "pattern": r"email.*varchar\(255\)", "check": "Email field length adequate", "severity": "info", }, { "pattern": r"password.*varchar", "check": "Password field exists", "severity": "info", }, { "pattern": r"bcrypt|passlib", "check": "Password hashing implemented", "severity": "info", }, { "pattern": r"jwt|JWT", "check": "JWT authentication present", "severity": "info", }, ] models_dir = self.project_root / "app" / "models" if models_dir.exists(): for file in models_dir.rglob("*.py"): if file.name.startswith("test_"): continue try: content = file.read_text(encoding="utf-8", errors="ignore") for item in check_items: if re.search(item["pattern"], content, re.IGNORECASE): warnings.append({ "file": str(file.relative_to(self.project_root)), "check": item["check"], }) except Exception: pass return AgentResult( success=True, message=f"152-FZ compliance: {len(warnings)} checks passed", data={ "checks_passed": len(warnings), "issues": issues, "warnings": warnings, }, ) async def _analyze_security_logs(self, db: AsyncSession | None = None) -> AgentResult: """Analyze security logs from LogEntry for the last 24 hours.""" alerts = [] if not db: return AgentResult( success=True, message="No DB session, log analysis skipped", data={"alerts": [], "total_logs": 0}, ) cutoff = datetime.now(timezone.utc) - timedelta(hours=24) result = await db.execute( select(LogEntry) .where(LogEntry.created_at >= cutoff) .order_by(LogEntry.created_at.desc()) ) logs = result.scalars().all() for log in logs: msg = log.message.lower() if any(kw in msg for kw in ["failed login", "invalid token", "unauthorized", "brute", "rate limit"]): alerts.append({ "message": log.message, "level": log.level, "source": log.source, "timestamp": log.created_at.isoformat(), }) elif log.level == "ERROR" and "security" in (log.source or "").lower(): alerts.append({ "message": log.message, "level": log.level, "source": log.source, "timestamp": log.created_at.isoformat(), }) return AgentResult( success=len(alerts) == 0, message=f"Анализ логов за 24ч: {len(logs)} записей, {len(alerts)} предупреждений", data={ "total_logs": len(logs), "alerts": alerts, }, ) async def get_metrics(self) -> dict[str, Any]: """Get SecurityAgent metrics.""" return { "agent_id": self.name, "version": self.version, "status": self.status.value, "last_run": self.last_run.isoformat() if self.last_run else None, }