codeframe-ai 0.9.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.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,250 @@
1
+ """Security scanning using bandit.
2
+
3
+ Scans code for security vulnerabilities using bandit and maps severity levels.
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import List
11
+
12
+ from codeframe.core.models import ReviewFinding
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class SecurityScanner:
18
+ """Scans code for security vulnerabilities using bandit.
19
+
20
+ Severity mapping:
21
+ - bandit HIGH → critical
22
+ - bandit MEDIUM → high
23
+ - bandit LOW → medium
24
+ """
25
+
26
+ def __init__(self, project_path: Path):
27
+ """Initialize SecurityScanner.
28
+
29
+ Args:
30
+ project_path: Path to project root directory
31
+ """
32
+ self.project_path = Path(project_path)
33
+
34
+ def analyze_file(self, file_path: Path) -> List[ReviewFinding]:
35
+ """Analyze a single file for security vulnerabilities.
36
+
37
+ Args:
38
+ file_path: Path to Python file to analyze
39
+
40
+ Returns:
41
+ List of ReviewFinding objects for security issues
42
+
43
+ Raises:
44
+ FileNotFoundError: If file doesn't exist
45
+ """
46
+ file_path = Path(file_path)
47
+
48
+ if not file_path.exists():
49
+ raise FileNotFoundError(f"File not found: {file_path}")
50
+
51
+ # Skip non-Python files
52
+ if file_path.suffix != ".py":
53
+ return []
54
+
55
+ # Read file to check if empty
56
+ try:
57
+ code = file_path.read_text()
58
+ except Exception as e:
59
+ logger.error(f"Error reading file {file_path}: {e}")
60
+ return []
61
+
62
+ if not code.strip():
63
+ return []
64
+
65
+ findings = []
66
+
67
+ try:
68
+ # Run bandit on the file
69
+ result = subprocess.run(
70
+ ["bandit", "-f", "json", str(file_path)],
71
+ capture_output=True,
72
+ text=True,
73
+ timeout=30,
74
+ )
75
+
76
+ # Parse JSON output
77
+ if result.stdout:
78
+ bandit_output = json.loads(result.stdout)
79
+ findings = self._parse_bandit_output(bandit_output, file_path)
80
+
81
+ except subprocess.TimeoutExpired:
82
+ logger.error(f"Bandit timeout analyzing {file_path}")
83
+ except json.JSONDecodeError as e:
84
+ logger.error(f"Error parsing bandit output for {file_path}: {e}")
85
+ except FileNotFoundError:
86
+ logger.warning("bandit not found. Install with: pip install bandit")
87
+ except Exception as e:
88
+ logger.error(f"Error running bandit on {file_path}: {e}")
89
+
90
+ return findings
91
+
92
+ def _parse_bandit_output(self, bandit_output: dict, file_path: Path) -> List[ReviewFinding]:
93
+ """Parse bandit JSON output into ReviewFinding objects.
94
+
95
+ Args:
96
+ bandit_output: Parsed JSON output from bandit
97
+ file_path: Path to file being analyzed
98
+
99
+ Returns:
100
+ List of ReviewFinding objects
101
+ """
102
+ findings = []
103
+
104
+ results = bandit_output.get("results", [])
105
+
106
+ for result in results:
107
+ # Map bandit severity to our severity levels
108
+ bandit_severity = result.get("issue_severity", "LOW")
109
+ severity = self._map_severity(bandit_severity)
110
+
111
+ # Extract details
112
+ issue_text = result.get("issue_text", "Security issue detected")
113
+ line_number = result.get("line_number", 1)
114
+ test_id = result.get("test_id", "")
115
+ test_name = result.get("test_name", "")
116
+
117
+ # Generate suggestion based on issue type
118
+ suggestion = self._generate_suggestion(test_id, test_name, issue_text)
119
+
120
+ # Create message
121
+ message = f"{issue_text}"
122
+ if test_id:
123
+ message = f"[{test_id}] {message}"
124
+
125
+ findings.append(
126
+ ReviewFinding(
127
+ category="security",
128
+ severity=severity,
129
+ file_path=str(file_path),
130
+ line_number=line_number,
131
+ message=message,
132
+ suggestion=suggestion,
133
+ tool="bandit",
134
+ )
135
+ )
136
+
137
+ return findings
138
+
139
+ def _map_severity(self, bandit_severity: str) -> str:
140
+ """Map bandit severity levels to our severity levels.
141
+
142
+ Args:
143
+ bandit_severity: Bandit severity (HIGH, MEDIUM, LOW)
144
+
145
+ Returns:
146
+ Our severity level (critical, high, medium, low)
147
+ """
148
+ mapping = {
149
+ "HIGH": "critical",
150
+ "MEDIUM": "high",
151
+ "LOW": "medium",
152
+ }
153
+ return mapping.get(bandit_severity.upper(), "medium")
154
+
155
+ def _generate_suggestion(self, test_id: str, test_name: str, issue_text: str) -> str:
156
+ """Generate remediation suggestion based on issue type.
157
+
158
+ Args:
159
+ test_id: Bandit test ID (e.g., B105, B608)
160
+ test_name: Bandit test name
161
+ issue_text: Issue description
162
+
163
+ Returns:
164
+ Remediation suggestion
165
+ """
166
+ # Common suggestions based on test ID
167
+ suggestions = {
168
+ "B105": "Use environment variables or a secrets manager instead of hardcoding passwords",
169
+ "B106": "Use environment variables or a secrets manager instead of hardcoding passwords",
170
+ "B107": "Use environment variables or a secrets manager instead of hardcoding sensitive data",
171
+ "B201": "Avoid using flask with debug=True in production",
172
+ "B608": "Use parameterized queries to prevent SQL injection",
173
+ "B602": "Use subprocess with shell=False and validate inputs",
174
+ "B603": "Use subprocess with shell=False to prevent command injection",
175
+ "B301": "Avoid using pickle for untrusted data",
176
+ "B506": "Avoid using yaml.load(), use yaml.safe_load() instead",
177
+ }
178
+
179
+ suggestion = suggestions.get(test_id)
180
+
181
+ if suggestion:
182
+ return suggestion
183
+
184
+ # Generic suggestions based on keywords
185
+ if "sql" in issue_text.lower():
186
+ return "Use parameterized queries or an ORM to prevent SQL injection"
187
+ elif "password" in issue_text.lower() or "secret" in issue_text.lower():
188
+ return "Store sensitive data in environment variables or a secrets manager"
189
+ elif "shell" in issue_text.lower() or "command" in issue_text.lower():
190
+ return "Avoid shell=True in subprocess calls and validate all inputs"
191
+ elif "pickle" in issue_text.lower():
192
+ return "Use safer serialization formats like JSON for untrusted data"
193
+ elif "yaml" in issue_text.lower():
194
+ return "Use yaml.safe_load() instead of yaml.load()"
195
+ else:
196
+ return "Review and fix this security issue before merging"
197
+
198
+ def analyze_files(self, file_paths: List[Path]) -> List[ReviewFinding]:
199
+ """Analyze multiple files for security vulnerabilities.
200
+
201
+ Args:
202
+ file_paths: List of file paths to analyze
203
+
204
+ Returns:
205
+ List of all findings from all files
206
+ """
207
+ all_findings = []
208
+
209
+ for file_path in file_paths:
210
+ try:
211
+ findings = self.analyze_file(file_path)
212
+ all_findings.extend(findings)
213
+ except Exception as e:
214
+ logger.error(f"Error analyzing {file_path}: {e}")
215
+
216
+ return all_findings
217
+
218
+ def calculate_score(self, file_paths: List[Path]) -> float:
219
+ """Calculate overall security score (0-100, higher is better).
220
+
221
+ Args:
222
+ file_paths: List of file paths to analyze
223
+
224
+ Returns:
225
+ Overall security score (0-100)
226
+ """
227
+ if not file_paths:
228
+ return 100.0 # No files = perfect score
229
+
230
+ findings = self.analyze_files(file_paths)
231
+
232
+ # Calculate penalty based on severity
233
+ penalty = 0
234
+ for finding in findings:
235
+ if finding.severity == "critical":
236
+ penalty += 30 # Critical security issues are very bad
237
+ elif finding.severity == "high":
238
+ penalty += 15
239
+ elif finding.severity == "medium":
240
+ penalty += 5
241
+ elif finding.severity == "low":
242
+ penalty += 2
243
+
244
+ # Normalize penalty based on number of files
245
+ penalty_per_file = penalty / len(file_paths)
246
+
247
+ # Calculate score (start at 100, subtract penalties)
248
+ score = max(0, 100 - penalty_per_file)
249
+
250
+ return score
@@ -0,0 +1,312 @@
1
+ """Rate limiting middleware for CodeFRAME API.
2
+
3
+ This module provides rate limiting functionality using slowapi, with support
4
+ for different rate limits per endpoint category and proper 429 responses.
5
+
6
+ Rate limit categories:
7
+ - auth: Authentication endpoints (login, register)
8
+ - standard: Standard API endpoints (CRUD operations)
9
+ - ai: AI/expensive operations (chat, generation)
10
+ - websocket: WebSocket connections
11
+
12
+ Key extraction:
13
+ - Authenticated requests: User ID from token
14
+ - Unauthenticated requests: Client IP address
15
+
16
+ Security:
17
+ - X-Forwarded-For is only trusted when request comes from a configured trusted proxy
18
+ - "unknown" IPs are logged and tracked for security monitoring
19
+ - Configure RATE_LIMIT_TRUSTED_PROXIES to define trusted proxy networks
20
+ """
21
+
22
+ import logging
23
+ from typing import Callable, Optional
24
+
25
+ from fastapi import Request
26
+ from fastapi.responses import JSONResponse
27
+ from slowapi import Limiter
28
+ from slowapi.errors import RateLimitExceeded
29
+
30
+ from codeframe.config.rate_limits import get_rate_limit_config
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Global limiter instance
35
+ _limiter: Optional[Limiter] = None
36
+ # Track if we've logged the disabled message to avoid duplicate logs
37
+ _logged_disabled: bool = False
38
+
39
+
40
+ def get_client_ip(request: Request) -> str:
41
+ """Extract client IP address from request with trusted proxy validation.
42
+
43
+ Only trusts X-Forwarded-For and X-Real-IP headers when the direct
44
+ connection is from a configured trusted proxy. This prevents header
45
+ spoofing attacks.
46
+
47
+ Security Note:
48
+ - If RATE_LIMIT_TRUSTED_PROXIES is not configured, proxy headers are ignored
49
+ - Configure trusted proxies when running behind a reverse proxy (nginx, ALB, etc.)
50
+
51
+ Args:
52
+ request: FastAPI request object
53
+
54
+ Returns:
55
+ Client IP address string, or "unknown" if not determinable
56
+ """
57
+ config = get_rate_limit_config()
58
+
59
+ # Get the direct connection IP
60
+ direct_ip = None
61
+ if request.client and request.client.host:
62
+ direct_ip = request.client.host
63
+
64
+ # Only trust proxy headers if direct connection is from a trusted proxy
65
+ if direct_ip and config.is_trusted_proxy(direct_ip):
66
+ # Check X-Forwarded-For header (may contain multiple IPs)
67
+ forwarded_for = request.headers.get("X-Forwarded-For")
68
+ if forwarded_for:
69
+ # Return first IP in the chain (real client)
70
+ client_ip = forwarded_for.split(",")[0].strip()
71
+ if client_ip:
72
+ return client_ip
73
+
74
+ # Check X-Real-IP header
75
+ real_ip = request.headers.get("X-Real-IP")
76
+ if real_ip:
77
+ return real_ip.strip()
78
+ elif direct_ip:
79
+ # Not from trusted proxy - check if headers were spoofed
80
+ if request.headers.get("X-Forwarded-For") or request.headers.get("X-Real-IP"):
81
+ logger.warning(
82
+ f"Proxy headers present from non-trusted IP {direct_ip}. "
83
+ f"Headers ignored. Configure RATE_LIMIT_TRUSTED_PROXIES if "
84
+ f"running behind a reverse proxy."
85
+ )
86
+
87
+ # Use direct connection IP
88
+ if direct_ip:
89
+ return direct_ip
90
+
91
+ # Unable to determine IP - log for security monitoring
92
+ logger.warning(
93
+ "Unable to determine client IP address. "
94
+ "This may indicate proxy misconfiguration. "
95
+ f"Path: {request.url.path}"
96
+ )
97
+ return "unknown"
98
+
99
+
100
+ def get_rate_limit_key(request: Request) -> str:
101
+ """Generate rate limit key for a request.
102
+
103
+ Uses user ID for authenticated requests, IP address for unauthenticated.
104
+ Applies stricter rate limiting for "unknown" IPs to mitigate DoS risk.
105
+
106
+ Args:
107
+ request: FastAPI request object
108
+
109
+ Returns:
110
+ Rate limit key string in format "user:{id}" or "ip:{address}"
111
+ """
112
+ # Check if user is authenticated (set by auth middleware)
113
+ user = getattr(getattr(request, "state", None), "user", None)
114
+
115
+ if user and hasattr(user, "id") and user.id:
116
+ return f"user:{user.id}"
117
+
118
+ # Fall back to IP address
119
+ client_ip = get_client_ip(request)
120
+
121
+ # Mark "unknown" IPs specially for potential stricter handling
122
+ if client_ip == "unknown":
123
+ # Use a unique key per request for unknown IPs
124
+ # This effectively gives each "unknown" request its own bucket
125
+ # preventing the shared bucket DoS attack
126
+ request_id = id(request)
127
+ return f"ip:unknown:{request_id}"
128
+
129
+ return f"ip:{client_ip}"
130
+
131
+
132
+ def get_rate_limiter() -> Optional[Limiter]:
133
+ """Get or create the rate limiter instance.
134
+
135
+ Returns:
136
+ Limiter instance if rate limiting is enabled, None otherwise
137
+ """
138
+ global _limiter
139
+ global _logged_disabled
140
+
141
+ config = get_rate_limit_config()
142
+
143
+ if not config.enabled:
144
+ if not _logged_disabled:
145
+ logger.info("Rate limiting is disabled")
146
+ _logged_disabled = True
147
+ return None
148
+
149
+ if _limiter is None:
150
+ # Create limiter with appropriate storage
151
+ if config.storage == "redis" and config.redis_url:
152
+ try:
153
+ _limiter = Limiter(
154
+ key_func=get_rate_limit_key,
155
+ storage_uri=config.redis_url,
156
+ )
157
+ logger.info("Rate limiter initialized with Redis storage")
158
+ except ImportError as e:
159
+ logger.error(f"Redis storage requested but redis module not available: {e}. Falling back to memory.")
160
+ _limiter = Limiter(key_func=get_rate_limit_key)
161
+ else:
162
+ _limiter = Limiter(key_func=get_rate_limit_key)
163
+ logger.info("Rate limiter initialized with in-memory storage")
164
+
165
+ return _limiter
166
+
167
+
168
+ async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
169
+ """Custom exception handler for rate limit exceeded errors.
170
+
171
+ Returns a proper 429 response with standard rate limit headers.
172
+ Also logs the event to the audit log for security monitoring.
173
+
174
+ Args:
175
+ request: FastAPI request object
176
+ exc: RateLimitExceeded exception
177
+
178
+ Returns:
179
+ JSONResponse with 429 status and rate limit headers
180
+ """
181
+ # Extract request info
182
+ client_ip = get_client_ip(request)
183
+ user = getattr(getattr(request, "state", None), "user", None)
184
+ user_id = user.id if user and hasattr(user, "id") and user.id else None
185
+ endpoint = request.url.path
186
+
187
+ # Log to standard logger
188
+ logger.warning(
189
+ f"Rate limit exceeded: path={endpoint}, "
190
+ f"ip={client_ip}, user_id={user_id}"
191
+ )
192
+
193
+ # Log to audit log for security monitoring
194
+ try:
195
+ db = getattr(getattr(request, "app", None), "state", None)
196
+ db = getattr(db, "db", None) if db else None
197
+ if db:
198
+ from codeframe.lib.audit_logger import AuditLogger, AuditEventType
199
+
200
+ audit = AuditLogger(db)
201
+ audit.log_rate_limit_event(
202
+ event_type=AuditEventType.RATE_LIMIT_EXCEEDED,
203
+ user_id=user_id,
204
+ ip_address=client_ip,
205
+ endpoint=endpoint,
206
+ limit_category=None, # Not easily determinable from exception
207
+ metadata={
208
+ "limit": str(exc.limit) if hasattr(exc, "limit") else None,
209
+ "retry_after": str(exc.detail) if hasattr(exc, "detail") else "60",
210
+ },
211
+ )
212
+ except Exception as e:
213
+ # Don't let audit logging failure affect the rate limit response
214
+ logger.debug(f"Failed to log rate limit event to audit log: {e}")
215
+
216
+ # Build response headers
217
+ headers = {
218
+ "Retry-After": str(exc.detail) if hasattr(exc, "detail") else "60",
219
+ }
220
+
221
+ # Add rate limit info headers if available
222
+ if hasattr(exc, "limit"):
223
+ headers["X-RateLimit-Limit"] = str(exc.limit)
224
+
225
+ response = JSONResponse(
226
+ status_code=429,
227
+ content={
228
+ "error": "rate_limit_exceeded",
229
+ "detail": "Too many requests. Please try again later.",
230
+ "retry_after": headers.get("Retry-After", "60"),
231
+ },
232
+ headers=headers,
233
+ )
234
+
235
+ return response
236
+
237
+
238
+ def _create_rate_limit_decorator(limit_key: str) -> Callable:
239
+ """Create a rate limit decorator for a specific limit category.
240
+
241
+ Args:
242
+ limit_key: Configuration key for the limit (e.g., 'standard_limit')
243
+
244
+ Returns:
245
+ Decorator function that applies the rate limit
246
+ """
247
+
248
+ def decorator():
249
+ """Rate limit decorator that reads limit from config."""
250
+
251
+ def wrapper(func: Callable) -> Callable:
252
+ # Get the limiter (returns None when rate limiting is disabled)
253
+ limiter = get_rate_limiter()
254
+
255
+ if limiter is None:
256
+ # Rate limiting globally disabled, return function as-is
257
+ return func
258
+
259
+ # Get the limit value from config
260
+ config = get_rate_limit_config()
261
+ limit_value = getattr(config, limit_key, "100/minute")
262
+
263
+ # Always apply the slowapi decorator, let slowapi handle the logic
264
+ return limiter.limit(limit_value)(func)
265
+
266
+ return wrapper
267
+
268
+ return decorator
269
+
270
+
271
+ # Rate limit decorators for each category
272
+ def rate_limit_auth() -> Callable:
273
+ """Decorator for authentication endpoint rate limits.
274
+
275
+ Default: 10 requests/minute (configurable via RATE_LIMIT_AUTH)
276
+ """
277
+ return _create_rate_limit_decorator("auth_limit")()
278
+
279
+
280
+ def rate_limit_standard() -> Callable:
281
+ """Decorator for standard API endpoint rate limits.
282
+
283
+ Default: 100 requests/minute (configurable via RATE_LIMIT_STANDARD)
284
+ """
285
+ return _create_rate_limit_decorator("standard_limit")()
286
+
287
+
288
+ def rate_limit_ai() -> Callable:
289
+ """Decorator for AI/expensive operation rate limits.
290
+
291
+ Default: 20 requests/minute (configurable via RATE_LIMIT_AI)
292
+ """
293
+ return _create_rate_limit_decorator("ai_limit")()
294
+
295
+
296
+ def rate_limit_websocket() -> Callable:
297
+ """Decorator for WebSocket connection rate limits.
298
+
299
+ Default: 30 connections/minute (configurable via RATE_LIMIT_WEBSOCKET)
300
+ """
301
+ return _create_rate_limit_decorator("websocket_limit")()
302
+
303
+
304
+ def reset_rate_limiter() -> None:
305
+ """Reset the global rate limiter instance.
306
+
307
+ Useful for testing to ensure clean state between tests.
308
+ """
309
+ global _limiter
310
+ global _logged_disabled
311
+ _limiter = None
312
+ _logged_disabled = False
File without changes