355 lines
14 KiB
Python
355 lines
14 KiB
Python
"""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,
|
|
} |