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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- 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
|