kollabor 0.4.9__py3-none-any.whl → 0.4.15__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.
- agents/__init__.py +2 -0
- agents/coder/__init__.py +0 -0
- agents/coder/agent.json +4 -0
- agents/coder/api-integration.md +2150 -0
- agents/coder/cli-pretty.md +765 -0
- agents/coder/code-review.md +1092 -0
- agents/coder/database-design.md +1525 -0
- agents/coder/debugging.md +1102 -0
- agents/coder/dependency-management.md +1397 -0
- agents/coder/git-workflow.md +1099 -0
- agents/coder/refactoring.md +1454 -0
- agents/coder/security-hardening.md +1732 -0
- agents/coder/system_prompt.md +1448 -0
- agents/coder/tdd.md +1367 -0
- agents/creative-writer/__init__.py +0 -0
- agents/creative-writer/agent.json +4 -0
- agents/creative-writer/character-development.md +1852 -0
- agents/creative-writer/dialogue-craft.md +1122 -0
- agents/creative-writer/plot-structure.md +1073 -0
- agents/creative-writer/revision-editing.md +1484 -0
- agents/creative-writer/system_prompt.md +690 -0
- agents/creative-writer/worldbuilding.md +2049 -0
- agents/data-analyst/__init__.py +30 -0
- agents/data-analyst/agent.json +4 -0
- agents/data-analyst/data-visualization.md +992 -0
- agents/data-analyst/exploratory-data-analysis.md +1110 -0
- agents/data-analyst/pandas-data-manipulation.md +1081 -0
- agents/data-analyst/sql-query-optimization.md +881 -0
- agents/data-analyst/statistical-analysis.md +1118 -0
- agents/data-analyst/system_prompt.md +928 -0
- agents/default/__init__.py +0 -0
- agents/default/agent.json +4 -0
- agents/default/dead-code.md +794 -0
- agents/default/explore-agent-system.md +585 -0
- agents/default/system_prompt.md +1448 -0
- agents/kollabor/__init__.py +0 -0
- agents/kollabor/analyze-plugin-lifecycle.md +175 -0
- agents/kollabor/analyze-terminal-rendering.md +388 -0
- agents/kollabor/code-review.md +1092 -0
- agents/kollabor/debug-mcp-integration.md +521 -0
- agents/kollabor/debug-plugin-hooks.md +547 -0
- agents/kollabor/debugging.md +1102 -0
- agents/kollabor/dependency-management.md +1397 -0
- agents/kollabor/git-workflow.md +1099 -0
- agents/kollabor/inspect-llm-conversation.md +148 -0
- agents/kollabor/monitor-event-bus.md +558 -0
- agents/kollabor/profile-performance.md +576 -0
- agents/kollabor/refactoring.md +1454 -0
- agents/kollabor/system_prompt copy.md +1448 -0
- agents/kollabor/system_prompt.md +757 -0
- agents/kollabor/trace-command-execution.md +178 -0
- agents/kollabor/validate-config.md +879 -0
- agents/research/__init__.py +0 -0
- agents/research/agent.json +4 -0
- agents/research/architecture-mapping.md +1099 -0
- agents/research/codebase-analysis.md +1077 -0
- agents/research/dependency-audit.md +1027 -0
- agents/research/performance-profiling.md +1047 -0
- agents/research/security-review.md +1359 -0
- agents/research/system_prompt.md +492 -0
- agents/technical-writer/__init__.py +0 -0
- agents/technical-writer/agent.json +4 -0
- agents/technical-writer/api-documentation.md +2328 -0
- agents/technical-writer/changelog-management.md +1181 -0
- agents/technical-writer/readme-writing.md +1360 -0
- agents/technical-writer/style-guide.md +1410 -0
- agents/technical-writer/system_prompt.md +653 -0
- agents/technical-writer/tutorial-creation.md +1448 -0
- core/__init__.py +0 -2
- core/application.py +343 -88
- core/cli.py +229 -10
- core/commands/menu_renderer.py +463 -59
- core/commands/registry.py +14 -9
- core/commands/system_commands.py +2461 -14
- core/config/loader.py +151 -37
- core/config/service.py +18 -6
- core/events/bus.py +29 -9
- core/events/executor.py +205 -75
- core/events/models.py +27 -8
- core/fullscreen/command_integration.py +20 -24
- core/fullscreen/components/__init__.py +10 -1
- core/fullscreen/components/matrix_components.py +1 -2
- core/fullscreen/components/space_shooter_components.py +654 -0
- core/fullscreen/plugin.py +5 -0
- core/fullscreen/renderer.py +52 -13
- core/fullscreen/session.py +52 -15
- core/io/__init__.py +29 -5
- core/io/buffer_manager.py +6 -1
- core/io/config_status_view.py +7 -29
- core/io/core_status_views.py +267 -347
- core/io/input/__init__.py +25 -0
- core/io/input/command_mode_handler.py +711 -0
- core/io/input/display_controller.py +128 -0
- core/io/input/hook_registrar.py +286 -0
- core/io/input/input_loop_manager.py +421 -0
- core/io/input/key_press_handler.py +502 -0
- core/io/input/modal_controller.py +1011 -0
- core/io/input/paste_processor.py +339 -0
- core/io/input/status_modal_renderer.py +184 -0
- core/io/input_errors.py +5 -1
- core/io/input_handler.py +211 -2452
- core/io/key_parser.py +7 -0
- core/io/layout.py +15 -3
- core/io/message_coordinator.py +111 -2
- core/io/message_renderer.py +129 -4
- core/io/status_renderer.py +147 -607
- core/io/terminal_renderer.py +97 -51
- core/io/terminal_state.py +21 -4
- core/io/visual_effects.py +816 -165
- core/llm/agent_manager.py +1063 -0
- core/llm/api_adapters/__init__.py +44 -0
- core/llm/api_adapters/anthropic_adapter.py +432 -0
- core/llm/api_adapters/base.py +241 -0
- core/llm/api_adapters/openai_adapter.py +326 -0
- core/llm/api_communication_service.py +167 -113
- core/llm/conversation_logger.py +322 -16
- core/llm/conversation_manager.py +556 -30
- core/llm/file_operations_executor.py +84 -32
- core/llm/llm_service.py +934 -103
- core/llm/mcp_integration.py +541 -57
- core/llm/message_display_service.py +135 -18
- core/llm/plugin_sdk.py +1 -2
- core/llm/profile_manager.py +1183 -0
- core/llm/response_parser.py +274 -56
- core/llm/response_processor.py +16 -3
- core/llm/tool_executor.py +6 -1
- core/logging/__init__.py +2 -0
- core/logging/setup.py +34 -6
- core/models/resume.py +54 -0
- core/plugins/__init__.py +4 -2
- core/plugins/base.py +127 -0
- core/plugins/collector.py +23 -161
- core/plugins/discovery.py +37 -3
- core/plugins/factory.py +6 -12
- core/plugins/registry.py +5 -17
- core/ui/config_widgets.py +128 -28
- core/ui/live_modal_renderer.py +2 -1
- core/ui/modal_actions.py +5 -0
- core/ui/modal_overlay_renderer.py +0 -60
- core/ui/modal_renderer.py +268 -7
- core/ui/modal_state_manager.py +29 -4
- core/ui/widgets/base_widget.py +7 -0
- core/updates/__init__.py +10 -0
- core/updates/version_check_service.py +348 -0
- core/updates/version_comparator.py +103 -0
- core/utils/config_utils.py +685 -526
- core/utils/plugin_utils.py +1 -1
- core/utils/session_naming.py +111 -0
- fonts/LICENSE +21 -0
- fonts/README.md +46 -0
- fonts/SymbolsNerdFont-Regular.ttf +0 -0
- fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
- fonts/__init__.py +44 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
- kollabor-0.4.15.dist-info/RECORD +228 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
- plugins/agent_orchestrator/__init__.py +39 -0
- plugins/agent_orchestrator/activity_monitor.py +181 -0
- plugins/agent_orchestrator/file_attacher.py +77 -0
- plugins/agent_orchestrator/message_injector.py +135 -0
- plugins/agent_orchestrator/models.py +48 -0
- plugins/agent_orchestrator/orchestrator.py +403 -0
- plugins/agent_orchestrator/plugin.py +976 -0
- plugins/agent_orchestrator/xml_parser.py +191 -0
- plugins/agent_orchestrator_plugin.py +9 -0
- plugins/enhanced_input/box_styles.py +1 -0
- plugins/enhanced_input/color_engine.py +19 -4
- plugins/enhanced_input/config.py +2 -2
- plugins/enhanced_input_plugin.py +61 -11
- plugins/fullscreen/__init__.py +6 -2
- plugins/fullscreen/example_plugin.py +1035 -222
- plugins/fullscreen/setup_wizard_plugin.py +592 -0
- plugins/fullscreen/space_shooter_plugin.py +131 -0
- plugins/hook_monitoring_plugin.py +436 -78
- plugins/query_enhancer_plugin.py +66 -30
- plugins/resume_conversation_plugin.py +1494 -0
- plugins/save_conversation_plugin.py +98 -32
- plugins/system_commands_plugin.py +70 -56
- plugins/tmux_plugin.py +154 -78
- plugins/workflow_enforcement_plugin.py +94 -92
- system_prompt/default.md +952 -886
- core/io/input_mode_manager.py +0 -402
- core/io/modal_interaction_handler.py +0 -315
- core/io/raw_input_processor.py +0 -946
- core/storage/__init__.py +0 -5
- core/storage/state_manager.py +0 -84
- core/ui/widget_integration.py +0 -222
- core/utils/key_reader.py +0 -171
- kollabor-0.4.9.dist-info/RECORD +0 -128
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1732 @@
|
|
|
1
|
+
<!-- Security Hardening skill - secure code from the start -->
|
|
2
|
+
|
|
3
|
+
security-hardening mode: SECURITY FIRST, ALWAYS
|
|
4
|
+
|
|
5
|
+
when this skill is active, you follow security best practices.
|
|
6
|
+
this is a comprehensive guide to writing secure, production-ready code.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PHASE 0: ENVIRONMENT VERIFICATION
|
|
10
|
+
|
|
11
|
+
before writing ANY code, verify security tools are available.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
check for security linters
|
|
15
|
+
|
|
16
|
+
<terminal>python -m bandit --version 2>/dev/null || echo "bandit not installed"</terminal>
|
|
17
|
+
|
|
18
|
+
if bandit not installed:
|
|
19
|
+
<terminal>pip install bandit</terminal>
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
check for dependency vulnerability scanner
|
|
23
|
+
|
|
24
|
+
<terminal>python -m safety --version 2>/dev/null || echo "safety not installed"</terminal>
|
|
25
|
+
|
|
26
|
+
if safety not installed:
|
|
27
|
+
<terminal>pip install safety</terminal>
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
check for secrets detection
|
|
31
|
+
|
|
32
|
+
<terminal>git-secrets --version 2>/dev/null || echo "git-secrets not installed"</terminal>
|
|
33
|
+
|
|
34
|
+
git-secrets installation varies by platform:
|
|
35
|
+
<terminal>brew install git-secrets</terminal> # macOS
|
|
36
|
+
<terminal>apt install git-secrets</terminal> # Ubuntu/Debian
|
|
37
|
+
|
|
38
|
+
initialize git-secrets in repo if not already done:
|
|
39
|
+
<terminal>git secrets --install</terminal>
|
|
40
|
+
<terminal>git secrets --register-aws</terminal>
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
check for pre-commit hooks
|
|
44
|
+
|
|
45
|
+
<terminal>pre-commit --version 2>/dev/null || echo "pre-commit not installed"</terminal>
|
|
46
|
+
|
|
47
|
+
if pre-commit not installed:
|
|
48
|
+
<terminal>pip install pre-commit</terminal>
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
check for existing security configuration
|
|
52
|
+
|
|
53
|
+
<terminal>ls -la .bandit 2>/dev/null || echo "no bandit config"</terminal>
|
|
54
|
+
<terminal>cat .gitignore 2>/dev/null | grep -E "\.env|secret|key" || echo "no secrets in gitignore"</terminal>
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
verify project security setup
|
|
58
|
+
|
|
59
|
+
<terminal>find . -name "*.py" -type f | head -5 | xargs -I {} bandit {} 2>&1 | head -20</terminal>
|
|
60
|
+
|
|
61
|
+
<terminal>safety check --bare 2>&1 | head -20</terminal>
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
PHASE 1: THE SECURITY MINDSET
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
think like an attacker
|
|
68
|
+
|
|
69
|
+
every input is malicious until proven otherwise.
|
|
70
|
+
every user request could be an attack.
|
|
71
|
+
every external system might be compromised.
|
|
72
|
+
|
|
73
|
+
questions to ask for every feature:
|
|
74
|
+
[1] what if the input is malicious?
|
|
75
|
+
[2] what if the user is not who they claim?
|
|
76
|
+
[3] what if the database is compromised?
|
|
77
|
+
[4] what if the external API is down or compromised?
|
|
78
|
+
[5] what if secrets are leaked?
|
|
79
|
+
|
|
80
|
+
secure by default principles:
|
|
81
|
+
|
|
82
|
+
deny by default, allow by exception
|
|
83
|
+
fail securely (closed, not open)
|
|
84
|
+
principle of least privilege
|
|
85
|
+
defense in depth (multiple layers)
|
|
86
|
+
minimize attack surface
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
the owasp top 10 (2021)
|
|
90
|
+
|
|
91
|
+
[1] broken access control
|
|
92
|
+
[2] cryptographic failures
|
|
93
|
+
[3] injection
|
|
94
|
+
[4] insecure design
|
|
95
|
+
[5] security misconfiguration
|
|
96
|
+
[6] vulnerable and outdated components
|
|
97
|
+
[7] identification and authentication failures
|
|
98
|
+
[8] software and data integrity failures
|
|
99
|
+
[9] security logging and monitoring failures
|
|
100
|
+
[10] server-side request forgery
|
|
101
|
+
|
|
102
|
+
memorize these. they cover 90% of security vulnerabilities.
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
PHASE 2: INPUT VALIDATION FUNDAMENTALS
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
never trust input
|
|
109
|
+
|
|
110
|
+
sources of untrusted input:
|
|
111
|
+
[ok] user input forms
|
|
112
|
+
[ok] url parameters and query strings
|
|
113
|
+
[ok] http headers
|
|
114
|
+
[ok] cookies
|
|
115
|
+
[ok] file uploads
|
|
116
|
+
[ok] webhooks
|
|
117
|
+
[ok] api requests from any source
|
|
118
|
+
[ok] database data (may be historic/compromised)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
validation layers
|
|
122
|
+
|
|
123
|
+
layer 1: schema validation
|
|
124
|
+
verify structure, types, required fields
|
|
125
|
+
|
|
126
|
+
layer 2: business rule validation
|
|
127
|
+
verify value ranges, relationships, constraints
|
|
128
|
+
|
|
129
|
+
layer 3: sanitization
|
|
130
|
+
remove/escape dangerous content
|
|
131
|
+
|
|
132
|
+
always validate in this order, never skip layers.
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
whitelist vs blacklist
|
|
136
|
+
|
|
137
|
+
# bad - blacklist (you'll miss something)
|
|
138
|
+
def sanitize_filename(filename):
|
|
139
|
+
dangerous = ["../", "..\\", "/etc/", "C:\\"]
|
|
140
|
+
for d in dangerous:
|
|
141
|
+
filename = filename.replace(d, "")
|
|
142
|
+
return filename
|
|
143
|
+
|
|
144
|
+
# good - whitelist (only allow known safe)
|
|
145
|
+
import re
|
|
146
|
+
|
|
147
|
+
def sanitize_filename(filename):
|
|
148
|
+
# only allow alphanumeric, dash, underscore, dot
|
|
149
|
+
cleaned = re.sub(r"[^a-zA-Z0-9._-]", "", filename)
|
|
150
|
+
# remove leading dots/dashes to prevent directory traversal
|
|
151
|
+
cleaned = cleaned.lstrip(".-")
|
|
152
|
+
return cleaned
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
validation examples
|
|
156
|
+
|
|
157
|
+
import re
|
|
158
|
+
from typing import Optional
|
|
159
|
+
from dataclasses import dataclass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class ValidatedEmail:
|
|
164
|
+
"""A validated email address."""
|
|
165
|
+
value: str
|
|
166
|
+
|
|
167
|
+
def __post_init__(self):
|
|
168
|
+
if not self._is_valid():
|
|
169
|
+
raise ValueError(f"Invalid email: {self.value}")
|
|
170
|
+
|
|
171
|
+
def _is_valid(self) -> bool:
|
|
172
|
+
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
173
|
+
return bool(re.match(pattern, self.value))
|
|
174
|
+
|
|
175
|
+
def __str__(self) -> str:
|
|
176
|
+
return self.value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# usage
|
|
180
|
+
try:
|
|
181
|
+
email = ValidatedEmail(user_input)
|
|
182
|
+
# safe to use email.value
|
|
183
|
+
except ValueError as e:
|
|
184
|
+
return {"error": str(e)}, 400
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
length limits always
|
|
188
|
+
|
|
189
|
+
def validate_username(username: str) -> str:
|
|
190
|
+
if not username:
|
|
191
|
+
raise ValueError("Username required")
|
|
192
|
+
if len(username) < 3:
|
|
193
|
+
raise ValueError("Username too short")
|
|
194
|
+
if len(username) > 50:
|
|
195
|
+
raise ValueError("Username too long")
|
|
196
|
+
if not re.match(r"^[a-zA-Z0-9_-]+$", username):
|
|
197
|
+
raise ValueError("Invalid characters")
|
|
198
|
+
return username
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
PHASE 3: PREVENTING INJECTION ATTACKS
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
sql injection
|
|
205
|
+
|
|
206
|
+
the classic vulnerability:
|
|
207
|
+
|
|
208
|
+
# vulnerable - never do this
|
|
209
|
+
def get_user(user_id):
|
|
210
|
+
query = f"SELECT * FROM users WHERE id = {user_id}"
|
|
211
|
+
return db.execute(query)
|
|
212
|
+
|
|
213
|
+
# attack: "1 OR 1=1; DROP TABLE users; --"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
prevention: parameterized queries
|
|
217
|
+
|
|
218
|
+
# safe - using parameterized queries
|
|
219
|
+
import sqlite3
|
|
220
|
+
|
|
221
|
+
def get_user(user_id: int):
|
|
222
|
+
query = "SELECT * FROM users WHERE id = ?"
|
|
223
|
+
cursor = db.execute(query, (user_id,))
|
|
224
|
+
return cursor.fetchone()
|
|
225
|
+
|
|
226
|
+
# safe - with ORM
|
|
227
|
+
from sqlalchemy import text
|
|
228
|
+
|
|
229
|
+
def get_user(user_id: int):
|
|
230
|
+
return db.session.query(User).filter(User.id == user_id).first()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# safe - with explicit type conversion
|
|
234
|
+
def get_user(user_id: str) -> Optional[User]:
|
|
235
|
+
try:
|
|
236
|
+
user_id_int = int(user_id) # force integer
|
|
237
|
+
except ValueError:
|
|
238
|
+
raise ValueError("Invalid user ID")
|
|
239
|
+
return User.query.get(user_id_int)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
dynamic queries still need parameters
|
|
243
|
+
|
|
244
|
+
# vulnerable even with some parameters
|
|
245
|
+
def search_users(column: str, value: str):
|
|
246
|
+
query = f"SELECT * FROM users WHERE {column} = ?"
|
|
247
|
+
return db.execute(query, (value,))
|
|
248
|
+
|
|
249
|
+
# attack: column = "id OR 1=1; DROP TABLE users; --"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# safe - validate column name against whitelist
|
|
253
|
+
ALLOWED_COLUMNS = {"id", "username", "email", "created_at"}
|
|
254
|
+
|
|
255
|
+
def search_users(column: str, value: str):
|
|
256
|
+
if column not in ALLOWED_COLUMNS:
|
|
257
|
+
raise ValueError(f"Invalid column: {column}")
|
|
258
|
+
query = f"SELECT * FROM users WHERE {column} = ?"
|
|
259
|
+
return db.execute(query, (value,))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
nosql injection
|
|
263
|
+
|
|
264
|
+
# vulnerable - mongodb injection
|
|
265
|
+
def find_user(username, password):
|
|
266
|
+
query = {"username": username, "password": password}
|
|
267
|
+
return db.users.find_one(query)
|
|
268
|
+
|
|
269
|
+
# attack: {"$ne": null} for username finds first user
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# safe - use type-safe queries
|
|
273
|
+
from typing import Dict, Any
|
|
274
|
+
|
|
275
|
+
def find_user(username: str, password: str) -> Optional[Dict[str, Any]]:
|
|
276
|
+
if not isinstance(username, str) or not isinstance(password, str):
|
|
277
|
+
raise TypeError("Username and password must be strings")
|
|
278
|
+
return db.users.find_one({
|
|
279
|
+
"username": username,
|
|
280
|
+
"password": password # hash this first!
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
command injection
|
|
285
|
+
|
|
286
|
+
# vulnerable - never pass user input to shell
|
|
287
|
+
import subprocess
|
|
288
|
+
|
|
289
|
+
def process_file(filename):
|
|
290
|
+
result = subprocess.run(
|
|
291
|
+
f"process_file {filename}",
|
|
292
|
+
shell=True, # DANGEROUS
|
|
293
|
+
capture_output=True
|
|
294
|
+
)
|
|
295
|
+
return result.stdout
|
|
296
|
+
|
|
297
|
+
# attack: filename = "file.txt; rm -rf /; #"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# safe - use list argument (no shell)
|
|
301
|
+
def process_file(filename: str):
|
|
302
|
+
# validate filename first
|
|
303
|
+
safe_filename = sanitize_filename(filename)
|
|
304
|
+
result = subprocess.run(
|
|
305
|
+
["process_file", safe_filename],
|
|
306
|
+
shell=False, # safe default
|
|
307
|
+
capture_output=True
|
|
308
|
+
)
|
|
309
|
+
return result.stdout
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# safe alternative - use python libraries
|
|
313
|
+
def process_pdf(filename: str):
|
|
314
|
+
safe_filename = sanitize_filename(filename)
|
|
315
|
+
# use PyPDF2 instead of calling external tool
|
|
316
|
+
import PyPDF2
|
|
317
|
+
with open(safe_filename, 'rb') as f:
|
|
318
|
+
reader = PyPDF2.PdfReader(f)
|
|
319
|
+
return reader.pages[0].extract_text()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
template injection
|
|
323
|
+
|
|
324
|
+
# vulnerable - jinja2 with user input
|
|
325
|
+
from jinja2 import Template
|
|
326
|
+
|
|
327
|
+
def render_greeting(template_str, name):
|
|
328
|
+
template = Template(template_str) # user controls template!
|
|
329
|
+
return template.render(name=name)
|
|
330
|
+
|
|
331
|
+
# attack: template_str = "{{config.items()}}"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# safe - separate template from data
|
|
335
|
+
from jinja2 import Environment, FileSystemLoader
|
|
336
|
+
|
|
337
|
+
env = Environment(loader=FileSystemLoader('templates/'))
|
|
338
|
+
template = env.get_template('greeting.html') # fixed template
|
|
339
|
+
|
|
340
|
+
def render_greeting(name):
|
|
341
|
+
return template.render(name=name)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
PHASE 4: CROSS-SITE SCRIPTING (XSS) PREVENTION
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
xss attack types
|
|
348
|
+
|
|
349
|
+
[1] stored xss - malicious code saved to database, shown to visitors
|
|
350
|
+
[2] reflected xss - malicious code in url, reflected back in response
|
|
351
|
+
[3] dom-based xss - malicious code executes in browser via javascript
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
output encoding
|
|
355
|
+
|
|
356
|
+
# vulnerable - raw output
|
|
357
|
+
def show_comment(comment):
|
|
358
|
+
return f"<div>{comment}</div>"
|
|
359
|
+
|
|
360
|
+
# attack: comment = "<script>alert('XSS')</script>"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# safe - html escape
|
|
364
|
+
from html import escape
|
|
365
|
+
|
|
366
|
+
def show_comment(comment):
|
|
367
|
+
escaped = escape(comment)
|
|
368
|
+
return f"<div>{escaped}</div>"
|
|
369
|
+
|
|
370
|
+
# result: <script>alert('XSS')</script>
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# safe - with template engine (auto-escaped)
|
|
374
|
+
from jinja2 import Template
|
|
375
|
+
|
|
376
|
+
template = Template("<div>{{ comment }}</div>", autoescape=True)
|
|
377
|
+
result = template.render(comment=user_input)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
context matters
|
|
381
|
+
|
|
382
|
+
def render_attribute(value):
|
|
383
|
+
# html body context
|
|
384
|
+
body = f"<div>{escape(value)}</div>"
|
|
385
|
+
# attribute context needs different escaping
|
|
386
|
+
attr = f'<div data-value="{escape(value, quote=True)}">'
|
|
387
|
+
# url context needs url encoding
|
|
388
|
+
import urllib.parse
|
|
389
|
+
url = f"/search?q={urllib.parse.quote(value)}"
|
|
390
|
+
return body
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
content security policy
|
|
394
|
+
|
|
395
|
+
add csp headers:
|
|
396
|
+
|
|
397
|
+
from flask import Flask, Response
|
|
398
|
+
|
|
399
|
+
app = Flask(__name__)
|
|
400
|
+
|
|
401
|
+
@app.after_request
|
|
402
|
+
def add_security_headers(response):
|
|
403
|
+
csp = (
|
|
404
|
+
"default-src 'self'; "
|
|
405
|
+
"script-src 'self' 'unsafe-inline' cdn.jsdelivr.net; "
|
|
406
|
+
"style-src 'self' 'unsafe-inline' cdn.jsdelivr.net; "
|
|
407
|
+
"img-src 'self' data: https:; "
|
|
408
|
+
"font-src 'self' cdn.jsdelivr.net; "
|
|
409
|
+
"connect-src 'self' api.example.com; "
|
|
410
|
+
"frame-ancestors 'none'; "
|
|
411
|
+
"base-uri 'self'; "
|
|
412
|
+
"form-action 'self';"
|
|
413
|
+
)
|
|
414
|
+
response.headers['Content-Security-Policy'] = csp
|
|
415
|
+
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
416
|
+
response.headers['X-Frame-Options'] = 'DENY'
|
|
417
|
+
response.headers['X-XSS-Protection'] = '1; mode=block'
|
|
418
|
+
return response
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
httponly cookies
|
|
422
|
+
|
|
423
|
+
from flask import make_response
|
|
424
|
+
|
|
425
|
+
response = make_response("Login successful")
|
|
426
|
+
response.set_cookie(
|
|
427
|
+
'session_token',
|
|
428
|
+
token,
|
|
429
|
+
httponly=True, # prevents javascript access
|
|
430
|
+
secure=True, # only send over https
|
|
431
|
+
samesite='Lax', # csrf protection
|
|
432
|
+
max_age=3600
|
|
433
|
+
)
|
|
434
|
+
return response
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
PHASE 5: CROSS-SITE REQUEST FORGERY (CSRF) PREVENTION
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
how csrf works
|
|
441
|
+
|
|
442
|
+
1. user logs into bank.com, gets session cookie
|
|
443
|
+
2. user visits evil.com
|
|
444
|
+
3. evil.com has form/action to bank.com/transfer
|
|
445
|
+
4. browser sends bank.com cookies automatically
|
|
446
|
+
5. transfer executes with user's credentials
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
synchronizer token pattern
|
|
450
|
+
|
|
451
|
+
import secrets
|
|
452
|
+
from flask import session, request
|
|
453
|
+
|
|
454
|
+
def generate_csrf_token():
|
|
455
|
+
if 'csrf_token' not in session:
|
|
456
|
+
session['csrf_token'] = secrets.token_hex(32)
|
|
457
|
+
return session['csrf_token']
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def validate_csrf_token():
|
|
461
|
+
token = session.get('csrf_token')
|
|
462
|
+
if not token or token != request.form.get('csrf_token'):
|
|
463
|
+
raise ValueError("Invalid CSRF token")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# form template
|
|
467
|
+
def render_transfer_form():
|
|
468
|
+
return f"""
|
|
469
|
+
<form method="POST" action="/transfer">
|
|
470
|
+
<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">
|
|
471
|
+
<input name="to_account" placeholder="Recipient">
|
|
472
|
+
<input name="amount" type="number" placeholder="Amount">
|
|
473
|
+
<button type="submit">Transfer</button>
|
|
474
|
+
</form>
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# form handler
|
|
479
|
+
def handle_transfer():
|
|
480
|
+
validate_csrf_token() # must be first
|
|
481
|
+
to_account = request.form['to_account']
|
|
482
|
+
amount = request.form['amount']
|
|
483
|
+
# ... process transfer ...
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
double submit cookie pattern
|
|
487
|
+
|
|
488
|
+
alternative when sessions aren't available:
|
|
489
|
+
|
|
490
|
+
import secrets
|
|
491
|
+
|
|
492
|
+
def set_csrf_cookie(response):
|
|
493
|
+
token = secrets.token_hex(32)
|
|
494
|
+
response.set_cookie(
|
|
495
|
+
'csrf_token',
|
|
496
|
+
token,
|
|
497
|
+
httponly=True,
|
|
498
|
+
secure=True,
|
|
499
|
+
samesite='Strict'
|
|
500
|
+
)
|
|
501
|
+
return token
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def validate_csrf_double_submit():
|
|
505
|
+
cookie_token = request.cookies.get('csrf_token')
|
|
506
|
+
form_token = request.form.get('csrf_token')
|
|
507
|
+
if not cookie_token or cookie_token != form_token:
|
|
508
|
+
raise ValueError("CSRF validation failed")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
sameSite cookies
|
|
512
|
+
|
|
513
|
+
modern browsers support sameSite attribute:
|
|
514
|
+
|
|
515
|
+
# strict - best security
|
|
516
|
+
response.set_cookie('session', token, samesite='Strict')
|
|
517
|
+
|
|
518
|
+
# lax - allows top-level navigations
|
|
519
|
+
response.set_cookie('session', token, samesite='Lax')
|
|
520
|
+
|
|
521
|
+
# none - for cross-origin requests (requires secure)
|
|
522
|
+
response.set_cookie('session', token, samesite='None', secure=True)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
PHASE 6: AUTHENTICATION AND AUTHORIZATION
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
secure password handling
|
|
529
|
+
|
|
530
|
+
import bcrypt
|
|
531
|
+
import secrets
|
|
532
|
+
from typing import Optional
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def hash_password(plain_password: str) -> str:
|
|
536
|
+
"""Hash a password using bcrypt."""
|
|
537
|
+
if len(plain_password) > 72:
|
|
538
|
+
raise ValueError("Password too long for bcrypt")
|
|
539
|
+
salt = bcrypt.gensalt(rounds=12)
|
|
540
|
+
return bcrypt.hashpw(plain_password.encode('utf-8'), salt).decode('utf-8')
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def verify_password(plain_password: str, hashed: str) -> bool:
|
|
544
|
+
"""Verify a password against its hash."""
|
|
545
|
+
try:
|
|
546
|
+
return bcrypt.checkpw(
|
|
547
|
+
plain_password.encode('utf-8'),
|
|
548
|
+
hashed.encode('utf-8')
|
|
549
|
+
)
|
|
550
|
+
except Exception:
|
|
551
|
+
return False
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
password requirements
|
|
555
|
+
|
|
556
|
+
import re
|
|
557
|
+
|
|
558
|
+
def validate_password_strength(password: str) -> list[str]:
|
|
559
|
+
"""Validate password strength, returning list of errors."""
|
|
560
|
+
errors = []
|
|
561
|
+
|
|
562
|
+
if len(password) < 12:
|
|
563
|
+
errors.append("Password must be at least 12 characters")
|
|
564
|
+
|
|
565
|
+
if len(password) > 128:
|
|
566
|
+
errors.append("Password too long (max 128 characters)")
|
|
567
|
+
|
|
568
|
+
if not re.search(r'[a-z]', password):
|
|
569
|
+
errors.append("Password must contain lowercase letters")
|
|
570
|
+
|
|
571
|
+
if not re.search(r'[A-Z]', password):
|
|
572
|
+
errors.append("Password must contain uppercase letters")
|
|
573
|
+
|
|
574
|
+
if not re.search(r'[0-9]', password):
|
|
575
|
+
errors.append("Password must contain digits")
|
|
576
|
+
|
|
577
|
+
if not re.search(r'[^a-zA-Z0-9]', password):
|
|
578
|
+
errors.append("Password must contain special characters")
|
|
579
|
+
|
|
580
|
+
# check for common passwords
|
|
581
|
+
common = ["password", "123456", "qwerty", "admin", "welcome"]
|
|
582
|
+
lower = password.lower()
|
|
583
|
+
for common_pwd in common:
|
|
584
|
+
if common_pwd in lower:
|
|
585
|
+
errors.append(f"Password contains common word: {common_pwd}")
|
|
586
|
+
|
|
587
|
+
return errors
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
secure session management
|
|
591
|
+
|
|
592
|
+
import secrets
|
|
593
|
+
from datetime import datetime, timedelta
|
|
594
|
+
from typing import Optional
|
|
595
|
+
|
|
596
|
+
SESSION_DURATION = timedelta(hours=1)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def create_session(user_id: int) -> str:
|
|
600
|
+
"""Create a new session token."""
|
|
601
|
+
token = secrets.token_urlsafe(32)
|
|
602
|
+
# store in database with expiration
|
|
603
|
+
db.execute(
|
|
604
|
+
"INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
|
|
605
|
+
(token, user_id, datetime.now() + SESSION_DURATION)
|
|
606
|
+
)
|
|
607
|
+
return token
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def validate_session(token: str) -> Optional[int]:
|
|
611
|
+
"""Validate session token and return user_id."""
|
|
612
|
+
session = db.execute(
|
|
613
|
+
"SELECT user_id, expires_at FROM sessions WHERE token = ?",
|
|
614
|
+
(token,)
|
|
615
|
+
).fetchone()
|
|
616
|
+
|
|
617
|
+
if not session:
|
|
618
|
+
return None
|
|
619
|
+
|
|
620
|
+
if datetime.now() > session['expires_at']:
|
|
621
|
+
db.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
# rotate session periodically
|
|
625
|
+
if random.random() < 0.1: # 10% chance to rotate
|
|
626
|
+
new_token = create_session(session['user_id'])
|
|
627
|
+
db.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
|
628
|
+
return new_token
|
|
629
|
+
|
|
630
|
+
return session['user_id']
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def revoke_session(token: str) -> None:
|
|
634
|
+
"""Revoke a session token."""
|
|
635
|
+
db.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def revoke_all_user_sessions(user_id: int) -> None:
|
|
639
|
+
"""Revoke all sessions for a user."""
|
|
640
|
+
db.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
authorization checks
|
|
644
|
+
|
|
645
|
+
from functools import wraps
|
|
646
|
+
from flask import g, jsonify
|
|
647
|
+
|
|
648
|
+
def require_role(*roles):
|
|
649
|
+
"""Decorator to require specific user roles."""
|
|
650
|
+
def decorator(f):
|
|
651
|
+
@wraps(f)
|
|
652
|
+
def wrapped(*args, **kwargs):
|
|
653
|
+
if not hasattr(g, 'user') or g.user is None:
|
|
654
|
+
return jsonify({"error": "Authentication required"}), 401
|
|
655
|
+
|
|
656
|
+
if g.user['role'] not in roles:
|
|
657
|
+
return jsonify({"error": "Insufficient permissions"}), 403
|
|
658
|
+
|
|
659
|
+
return f(*args, **kwargs)
|
|
660
|
+
return wrapped
|
|
661
|
+
return decorator
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def require_ownership(resource_type: str):
|
|
665
|
+
"""Decorator to require user owns the resource."""
|
|
666
|
+
def decorator(f):
|
|
667
|
+
@wraps(f)
|
|
668
|
+
def wrapped(*args, **kwargs):
|
|
669
|
+
resource_id = kwargs.get('id')
|
|
670
|
+
user_id = g.user['id']
|
|
671
|
+
|
|
672
|
+
# check ownership in database
|
|
673
|
+
owner_id = db.execute(
|
|
674
|
+
f"SELECT user_id FROM {resource_type} WHERE id = ?",
|
|
675
|
+
(resource_id,)
|
|
676
|
+
).fetchone()
|
|
677
|
+
|
|
678
|
+
if not owner_id or owner_id['user_id'] != user_id:
|
|
679
|
+
return jsonify({"error": "Access denied"}), 403
|
|
680
|
+
|
|
681
|
+
return f(*args, **kwargs)
|
|
682
|
+
return wrapped
|
|
683
|
+
return decorator
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
# usage
|
|
687
|
+
@app.route('/admin/users')
|
|
688
|
+
@require_role('admin')
|
|
689
|
+
def admin_users():
|
|
690
|
+
return jsonify({"users": list_users()})
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
@app.route('/api/posts/<int:id>', methods=['DELETE'])
|
|
694
|
+
@require_ownership('posts')
|
|
695
|
+
def delete_post(id):
|
|
696
|
+
return delete_post(id)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
rate limiting login
|
|
700
|
+
|
|
701
|
+
from functools import lru_cache
|
|
702
|
+
import time
|
|
703
|
+
|
|
704
|
+
MAX_ATTEMPTS = 5
|
|
705
|
+
LOCKOUT_DURATION = 900 # 15 minutes
|
|
706
|
+
|
|
707
|
+
@lru_cache(maxsize=10000)
|
|
708
|
+
def get_login_attempts(identifier: str) -> tuple[int, float]:
|
|
709
|
+
"""Get (attempt_count, last_attempt_time) for identifier."""
|
|
710
|
+
return (0, 0)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def record_failed_login(identifier: str) -> bool:
|
|
714
|
+
"""Record failed login, return True if locked out."""
|
|
715
|
+
attempts, last_time = get_login_attempts(identifier)
|
|
716
|
+
now = time.time()
|
|
717
|
+
|
|
718
|
+
# reset if lockout period passed
|
|
719
|
+
if now - last_time > LOCKOUT_DURATION:
|
|
720
|
+
attempts = 0
|
|
721
|
+
|
|
722
|
+
attempts += 1
|
|
723
|
+
# cache with expiration would be better
|
|
724
|
+
return attempts >= MAX_ATTEMPTS
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def is_locked_out(identifier: str) -> bool:
|
|
728
|
+
"""Check if identifier is currently locked out."""
|
|
729
|
+
attempts, last_time = get_login_attempts(identifier)
|
|
730
|
+
if attempts >= MAX_ATTEMPTS:
|
|
731
|
+
if time.time() - last_time < LOCKOUT_DURATION:
|
|
732
|
+
return True
|
|
733
|
+
return False
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
PHASE 7: SECRETS MANAGEMENT
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
never hardcode secrets
|
|
740
|
+
|
|
741
|
+
[x] api keys in source code
|
|
742
|
+
[x] database passwords in source code
|
|
743
|
+
[x] jwt secrets in source code
|
|
744
|
+
[x] private keys in repository
|
|
745
|
+
[x] credentials in config files
|
|
746
|
+
|
|
747
|
+
[ok] environment variables
|
|
748
|
+
[ok] secret management services
|
|
749
|
+
[ok] encrypted config files
|
|
750
|
+
[ok] runtime secret injection
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
environment variables
|
|
754
|
+
|
|
755
|
+
import os
|
|
756
|
+
from typing import Optional
|
|
757
|
+
|
|
758
|
+
def get_required_env(key: str) -> str:
|
|
759
|
+
"""Get required environment variable or raise error."""
|
|
760
|
+
value = os.getenv(key)
|
|
761
|
+
if not value:
|
|
762
|
+
raise ValueError(f"Required environment variable {key} not set")
|
|
763
|
+
return value
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def get_optional_env(key: str, default: Optional[str] = None) -> Optional[str]:
|
|
767
|
+
"""Get optional environment variable with default."""
|
|
768
|
+
return os.getenv(key, default)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
# usage
|
|
772
|
+
database_url = get_required_env('DATABASE_URL')
|
|
773
|
+
api_key = get_optional_env('API_KEY', 'default-key')
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
secret validation
|
|
777
|
+
|
|
778
|
+
def validate_database_url(url: str) -> str:
|
|
779
|
+
"""Validate database URL format."""
|
|
780
|
+
if not url.startswith(('postgresql://', 'postgres://', 'mysql://')):
|
|
781
|
+
raise ValueError("Invalid database URL scheme")
|
|
782
|
+
|
|
783
|
+
# check for credentials in url
|
|
784
|
+
# warn if not using ssl
|
|
785
|
+
if '?' not in url or 'sslmode' not in url:
|
|
786
|
+
raise ValueError("Database connection must use SSL")
|
|
787
|
+
|
|
788
|
+
return url
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
secrets file with git-secrets
|
|
792
|
+
|
|
793
|
+
# .gitignore should include:
|
|
794
|
+
.env
|
|
795
|
+
.env.local
|
|
796
|
+
.env.*.local
|
|
797
|
+
*.key
|
|
798
|
+
*.pem
|
|
799
|
+
secrets.json
|
|
800
|
+
.secrets/
|
|
801
|
+
|
|
802
|
+
# git-secrets patterns to add:
|
|
803
|
+
git secrets --add 'password\s*=\s*["\']?[^"\']+$'
|
|
804
|
+
git secrets --add 'api_key\s*=\s*["\']?[^"\']+$'
|
|
805
|
+
git secrets --add 'secret\s*=\s*["\']?[^"\']+$'
|
|
806
|
+
git secrets --add 'AKIA[0-9A-Z]{16}' # AWS access keys
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
rotate secrets
|
|
810
|
+
|
|
811
|
+
from datetime import datetime, timedelta
|
|
812
|
+
import secrets
|
|
813
|
+
|
|
814
|
+
class SecretRotator:
|
|
815
|
+
"""Manage secret rotation."""
|
|
816
|
+
|
|
817
|
+
def __init__(self, max_age_days: int = 90):
|
|
818
|
+
self.max_age = timedelta(days=max_age_days)
|
|
819
|
+
|
|
820
|
+
def should_rotate(self, created_at: datetime) -> bool:
|
|
821
|
+
"""Check if secret needs rotation."""
|
|
822
|
+
return datetime.now() - created_at > self.max_age
|
|
823
|
+
|
|
824
|
+
def generate_new_secret(self) -> str:
|
|
825
|
+
"""Generate a new secret."""
|
|
826
|
+
return secrets.token_urlsafe(32)
|
|
827
|
+
|
|
828
|
+
def rotate_api_key(self, old_key: str) -> str:
|
|
829
|
+
"""Rotate an API key."""
|
|
830
|
+
new_key = self.generate_new_secret()
|
|
831
|
+
# store both temporarily for graceful transition
|
|
832
|
+
db.execute(
|
|
833
|
+
"INSERT INTO api_keys (key, old_key, expires_at) VALUES (?, ?, ?)",
|
|
834
|
+
(new_key, old_key, datetime.now() + timedelta(hours=24))
|
|
835
|
+
)
|
|
836
|
+
return new_key
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
PHASE 8: CRYPTOGRAPHY BEST PRACTICES
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
use established libraries
|
|
843
|
+
|
|
844
|
+
[ok] cryptography.io - general cryptography
|
|
845
|
+
[ok] PyNaCl - modern crypto (libsodium bindings)
|
|
846
|
+
[ok] bcrypt - password hashing
|
|
847
|
+
[x] hashlib.md5 - broken for security
|
|
848
|
+
[x] hashlib.sha1 - broken for security
|
|
849
|
+
[x] custom crypto algorithms - never roll your own
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
secure hashing
|
|
853
|
+
|
|
854
|
+
from cryptography.hazmat.primitives import hashes
|
|
855
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2
|
|
856
|
+
|
|
857
|
+
def derive_key(password: bytes, salt: bytes) -> bytes:
|
|
858
|
+
"""Derive a key from password using PBKDF2."""
|
|
859
|
+
kdf = PBKDF2(
|
|
860
|
+
algorithm=hashes.SHA256(),
|
|
861
|
+
length=32,
|
|
862
|
+
salt=salt,
|
|
863
|
+
iterations=480000, # owasp recommendation
|
|
864
|
+
)
|
|
865
|
+
return kdf.derive(password)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
secure random
|
|
869
|
+
|
|
870
|
+
import secrets
|
|
871
|
+
|
|
872
|
+
# for tokens, session ids, api keys
|
|
873
|
+
token = secrets.token_hex(32) # 64 hex characters
|
|
874
|
+
token = secrets.token_urlsafe(32) # url-safe base64
|
|
875
|
+
token = secrets.token_bytes(32) # raw bytes
|
|
876
|
+
|
|
877
|
+
# never use:
|
|
878
|
+
random.random() # predictable
|
|
879
|
+
random.randint() # predictable
|
|
880
|
+
os.urandom() # okay, but secrets is better
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
encryption at rest
|
|
884
|
+
|
|
885
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
886
|
+
|
|
887
|
+
def encrypt_data(data: bytes, key: bytes) -> bytes:
|
|
888
|
+
"""Encrypt data using AES-GCM (authenticated encryption)."""
|
|
889
|
+
aesgcm = AESGCM(key)
|
|
890
|
+
nonce = secrets.token_bytes(12) # 96-bit nonce for GCM
|
|
891
|
+
ciphertext = aesgcm.encrypt(nonce, data, None)
|
|
892
|
+
return nonce + ciphertext # prepend nonce for storage
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def decrypt_data(ciphertext: bytes, key: bytes) -> bytes:
|
|
896
|
+
"""Decrypt data using AES-GCM."""
|
|
897
|
+
aesgcm = AESGCM(key)
|
|
898
|
+
nonce = ciphertext[:12] # extract nonce
|
|
899
|
+
return aesgcm.decrypt(nonce, ciphertext[12:], None)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
# usage
|
|
903
|
+
key = secrets.token_bytes(32) # 256-bit key
|
|
904
|
+
encrypted = encrypt_data(sensitive_data.encode(), key)
|
|
905
|
+
decrypted = decrypt_data(encrypted, key)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
PHASE 9: DEPENDENCY SECURITY
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
audit dependencies regularly
|
|
912
|
+
|
|
913
|
+
<terminal>safety check --full-report</terminal>
|
|
914
|
+
|
|
915
|
+
<terminal>pip install pip-audit</terminal>
|
|
916
|
+
<terminal>pip-audit</terminal>
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
<terminal>pip install bandit</terminal>
|
|
920
|
+
<terminal>bandit -r . -f json -o security-report.json</terminal>
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
pin dependencies
|
|
924
|
+
|
|
925
|
+
requirements.txt with hashes:
|
|
926
|
+
|
|
927
|
+
# generate with:
|
|
928
|
+
<terminal>pip freeze > requirements.txt</terminal>
|
|
929
|
+
<terminal>pip hash <requirements.txt > requirements-hashes.txt</terminal>
|
|
930
|
+
|
|
931
|
+
# or use pip-tools:
|
|
932
|
+
<terminal>pip install pip-tools</terminal>
|
|
933
|
+
<terminal>pip-compile requirements.in --generate-hashes</terminal>
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
lock files
|
|
937
|
+
|
|
938
|
+
use pyproject.toml with poetry or pipenv:
|
|
939
|
+
|
|
940
|
+
[tool.poetry.dependencies]
|
|
941
|
+
python = "^3.11"
|
|
942
|
+
fastapi = "^2.0.0"
|
|
943
|
+
pydantic = "^2.0.0"
|
|
944
|
+
|
|
945
|
+
# lock file pins exact versions:
|
|
946
|
+
# poetry.lock
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
vulnerability scanning in ci
|
|
950
|
+
|
|
951
|
+
# .github/workflows/security.yml
|
|
952
|
+
name: Security Scan
|
|
953
|
+
|
|
954
|
+
on: [push, pull_request]
|
|
955
|
+
|
|
956
|
+
jobs:
|
|
957
|
+
security:
|
|
958
|
+
runs-on: ubuntu-latest
|
|
959
|
+
steps:
|
|
960
|
+
- uses: actions/checkout@v3
|
|
961
|
+
|
|
962
|
+
- name: Run Safety Check
|
|
963
|
+
run: |
|
|
964
|
+
pip install safety
|
|
965
|
+
safety check --continue-on-error
|
|
966
|
+
|
|
967
|
+
- name: Run Bandit
|
|
968
|
+
run: |
|
|
969
|
+
pip install bandit
|
|
970
|
+
bandit -r . -f json -o bandit-report.json
|
|
971
|
+
|
|
972
|
+
- name: Upload Reports
|
|
973
|
+
uses: actions/upload-artifact@v3
|
|
974
|
+
with:
|
|
975
|
+
name: security-reports
|
|
976
|
+
path: |
|
|
977
|
+
bandit-report.json
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
abandonware detection
|
|
981
|
+
|
|
982
|
+
check for unmaintained packages:
|
|
983
|
+
|
|
984
|
+
<terminal>pip install depcheck</terminal>
|
|
985
|
+
<terminal>depcheck</terminal>
|
|
986
|
+
|
|
987
|
+
manually verify:
|
|
988
|
+
- last release date on pypi
|
|
989
|
+
- open issues/prs on github
|
|
990
|
+
- security advisories
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
PHASE 10: SECURE CONFIGURATION
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
secure defaults
|
|
997
|
+
|
|
998
|
+
import os
|
|
999
|
+
|
|
1000
|
+
class Config:
|
|
1001
|
+
"""Secure configuration defaults."""
|
|
1002
|
+
|
|
1003
|
+
# security
|
|
1004
|
+
SECRET_KEY = os.getenv('SECRET_KEY') or secrets.token_hex(32)
|
|
1005
|
+
DEBUG = False # always false in production
|
|
1006
|
+
TESTING = False
|
|
1007
|
+
|
|
1008
|
+
# sessions
|
|
1009
|
+
SESSION_COOKIE_SECURE = True
|
|
1010
|
+
SESSION_COOKIE_HTTPONLY = True
|
|
1011
|
+
SESSION_COOKIE_SAMESITE = 'Lax'
|
|
1012
|
+
PERMANENT_SESSION_LIFETIME = timedelta(hours=1)
|
|
1013
|
+
|
|
1014
|
+
# ssl/tls
|
|
1015
|
+
FORCE_HTTPS = True
|
|
1016
|
+
HSTS_ENABLED = True
|
|
1017
|
+
HSTS_MAX_AGE = 31536000 # 1 year
|
|
1018
|
+
|
|
1019
|
+
# limits
|
|
1020
|
+
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB
|
|
1021
|
+
MAX_REQUEST_SIZE = 1024 * 1024 # 1MB
|
|
1022
|
+
|
|
1023
|
+
# rate limiting
|
|
1024
|
+
RATE_LIMIT_ENABLED = True
|
|
1025
|
+
RATE_LIMIT_PER_MINUTE = 60
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
environment-specific configs
|
|
1029
|
+
|
|
1030
|
+
class DevelopmentConfig(Config):
|
|
1031
|
+
"""Development configuration."""
|
|
1032
|
+
DEBUG = True
|
|
1033
|
+
TESTING = False
|
|
1034
|
+
|
|
1035
|
+
# allow http in dev
|
|
1036
|
+
FORCE_HTTPS = False
|
|
1037
|
+
SESSION_COOKIE_SECURE = False
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
class TestingConfig(Config):
|
|
1041
|
+
"""Testing configuration."""
|
|
1042
|
+
TESTING = True
|
|
1043
|
+
DEBUG = True
|
|
1044
|
+
|
|
1045
|
+
# use in-memory database for tests
|
|
1046
|
+
DATABASE_URL = 'sqlite:///:memory:'
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
class ProductionConfig(Config):
|
|
1050
|
+
"""Production configuration - most secure."""
|
|
1051
|
+
DEBUG = False
|
|
1052
|
+
TESTING = False
|
|
1053
|
+
|
|
1054
|
+
# enforce security
|
|
1055
|
+
FORCE_HTTPS = True
|
|
1056
|
+
HSTS_ENABLED = True
|
|
1057
|
+
|
|
1058
|
+
# production-specific
|
|
1059
|
+
SENTRY_DSN = os.getenv('SENTRY_DSN')
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
config_by_env = {
|
|
1063
|
+
'development': DevelopmentConfig,
|
|
1064
|
+
'testing': TestingConfig,
|
|
1065
|
+
'production': ProductionConfig,
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
def get_config() -> Config:
|
|
1069
|
+
"""Get config based on environment."""
|
|
1070
|
+
env = os.getenv('FLASK_ENV', 'production')
|
|
1071
|
+
return config_by_env.get(env, ProductionConfig)()
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
disable debug in production
|
|
1075
|
+
|
|
1076
|
+
critical check:
|
|
1077
|
+
|
|
1078
|
+
def ensure_production_safety():
|
|
1079
|
+
"""Fail fast if debug mode enabled in production."""
|
|
1080
|
+
env = os.getenv('FLASK_ENV', 'production')
|
|
1081
|
+
debug = os.getenv('DEBUG', 'false').lower() == 'true'
|
|
1082
|
+
|
|
1083
|
+
if env == 'production' and debug:
|
|
1084
|
+
raise RuntimeError(
|
|
1085
|
+
"DEBUG mode enabled in production. "
|
|
1086
|
+
"This exposes sensitive information and is a security risk."
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
PHASE 11: LOGGING AND MONITORING
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
security event logging
|
|
1094
|
+
|
|
1095
|
+
import logging
|
|
1096
|
+
from datetime import datetime
|
|
1097
|
+
|
|
1098
|
+
security_logger = logging.getLogger('security')
|
|
1099
|
+
|
|
1100
|
+
class SecurityEvent:
|
|
1101
|
+
"""Log security-relevant events."""
|
|
1102
|
+
|
|
1103
|
+
EVENTS = {
|
|
1104
|
+
'auth_success',
|
|
1105
|
+
'auth_failure',
|
|
1106
|
+
'permission_denied',
|
|
1107
|
+
'rate_limit_exceeded',
|
|
1108
|
+
'suspicious_input',
|
|
1109
|
+
'csrf_failure',
|
|
1110
|
+
'injection_attempt',
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
@classmethod
|
|
1114
|
+
def log(cls, event_type: str, user_id: Optional[int],
|
|
1115
|
+
details: dict, ip: str, user_agent: str):
|
|
1116
|
+
"""Log a security event."""
|
|
1117
|
+
if event_type not in cls.EVENTS:
|
|
1118
|
+
raise ValueError(f"Unknown event type: {event_type}")
|
|
1119
|
+
|
|
1120
|
+
security_logger.warning({
|
|
1121
|
+
'event': event_type,
|
|
1122
|
+
'timestamp': datetime.now().isoformat(),
|
|
1123
|
+
'user_id': user_id,
|
|
1124
|
+
'ip': ip,
|
|
1125
|
+
'user_agent': user_agent,
|
|
1126
|
+
'details': details,
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
# usage
|
|
1131
|
+
SecurityEvent.log(
|
|
1132
|
+
'auth_failure',
|
|
1133
|
+
user_id=None,
|
|
1134
|
+
details={'username': input_username},
|
|
1135
|
+
ip=request.remote_addr,
|
|
1136
|
+
user_agent=request.headers.get('User-Agent', '')
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
sanitize logs
|
|
1141
|
+
|
|
1142
|
+
def sanitize_log_data(data: dict) -> dict:
|
|
1143
|
+
"""Remove sensitive data before logging."""
|
|
1144
|
+
sensitive_keys = {
|
|
1145
|
+
'password', 'passwd', 'pwd',
|
|
1146
|
+
'token', 'secret', 'api_key',
|
|
1147
|
+
'ssn', 'credit_card', 'cc',
|
|
1148
|
+
'session', 'cookie',
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
cleaned = {}
|
|
1152
|
+
for key, value in data.items():
|
|
1153
|
+
if any(sensitive in key.lower() for sensitive in sensitive_keys):
|
|
1154
|
+
cleaned[key] = '[REDACTED]'
|
|
1155
|
+
elif isinstance(value, dict):
|
|
1156
|
+
cleaned[key] = sanitize_log_data(value)
|
|
1157
|
+
else:
|
|
1158
|
+
cleaned[key] = value
|
|
1159
|
+
return cleaned
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
log injection prevention
|
|
1163
|
+
|
|
1164
|
+
import re
|
|
1165
|
+
|
|
1166
|
+
def sanitize_log_input(text: str) -> str:
|
|
1167
|
+
"""Prevent log injection attacks."""
|
|
1168
|
+
# remove crlf characters
|
|
1169
|
+
text = re.sub(r'[\r\n]', '', text)
|
|
1170
|
+
# limit length
|
|
1171
|
+
text = text[:1000]
|
|
1172
|
+
return text
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
PHASE 12: API SECURITY
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
api key management
|
|
1179
|
+
|
|
1180
|
+
from typing import Optional
|
|
1181
|
+
import secrets
|
|
1182
|
+
|
|
1183
|
+
class APIKeyManager:
|
|
1184
|
+
"""Manage API keys for external access."""
|
|
1185
|
+
|
|
1186
|
+
def create_key(self, user_id: int, name: str) -> str:
|
|
1187
|
+
"""Create a new API key."""
|
|
1188
|
+
prefix = f"kk_{user_id}_"
|
|
1189
|
+
key_secret = secrets.token_urlsafe(32)
|
|
1190
|
+
full_key = f"{prefix}{key_secret}"
|
|
1191
|
+
|
|
1192
|
+
# store hash, not the key itself
|
|
1193
|
+
key_hash = hash_api_key(full_key)
|
|
1194
|
+
db.execute(
|
|
1195
|
+
"INSERT INTO api_keys (user_id, name, key_hash, created_at) "
|
|
1196
|
+
"VALUES (?, ?, ?, ?)",
|
|
1197
|
+
(user_id, name, key_hash, datetime.now())
|
|
1198
|
+
)
|
|
1199
|
+
return full_key
|
|
1200
|
+
|
|
1201
|
+
def validate_key(self, key: str) -> Optional[dict]:
|
|
1202
|
+
"""Validate an API key and return user info."""
|
|
1203
|
+
key_hash = hash_api_key(key)
|
|
1204
|
+
result = db.execute(
|
|
1205
|
+
"SELECT user_id, name, scopes FROM api_keys WHERE key_hash = ? AND active = 1",
|
|
1206
|
+
(key_hash,)
|
|
1207
|
+
).fetchone()
|
|
1208
|
+
return result
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def hash_api_key(key: str) -> str:
|
|
1212
|
+
"""Hash an API key for storage."""
|
|
1213
|
+
import hashlib
|
|
1214
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
rate limiting
|
|
1218
|
+
|
|
1219
|
+
from functools import wraps
|
|
1220
|
+
import time
|
|
1221
|
+
from collections import defaultdict
|
|
1222
|
+
|
|
1223
|
+
class RateLimiter:
|
|
1224
|
+
"""Simple in-memory rate limiter."""
|
|
1225
|
+
|
|
1226
|
+
def __init__(self, requests_per_minute: int = 60):
|
|
1227
|
+
self.rpm = requests_per_minute
|
|
1228
|
+
self.requests = defaultdict(list)
|
|
1229
|
+
|
|
1230
|
+
def is_allowed(self, identifier: str) -> bool:
|
|
1231
|
+
"""Check if request is allowed."""
|
|
1232
|
+
now = time.time()
|
|
1233
|
+
minute_ago = now - 60
|
|
1234
|
+
|
|
1235
|
+
# clean old requests
|
|
1236
|
+
self.requests[identifier] = [
|
|
1237
|
+
t for t in self.requests[identifier] if t > minute_ago
|
|
1238
|
+
]
|
|
1239
|
+
|
|
1240
|
+
if len(self.requests[identifier]) >= self.rpm:
|
|
1241
|
+
return False
|
|
1242
|
+
|
|
1243
|
+
self.requests[identifier].append(now)
|
|
1244
|
+
return True
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
# decorator
|
|
1248
|
+
rate_limiter = RateLimiter(requests_per_minute=60)
|
|
1249
|
+
|
|
1250
|
+
def rate_limit(f):
|
|
1251
|
+
@wraps(f)
|
|
1252
|
+
def wrapped(*args, **kwargs):
|
|
1253
|
+
identifier = request.remote_addr
|
|
1254
|
+
if not rate_limiter.is_allowed(identifier):
|
|
1255
|
+
return jsonify({"error": "Rate limit exceeded"}), 429
|
|
1256
|
+
return f(*args, **kwargs)
|
|
1257
|
+
return wrapped
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
api versioning
|
|
1261
|
+
|
|
1262
|
+
@app.route('/api/v1/users')
|
|
1263
|
+
def list_users_v1():
|
|
1264
|
+
# deprecated but maintained
|
|
1265
|
+
return jsonify({"users": get_users()})
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
@app.route('/api/v2/users')
|
|
1269
|
+
def list_users_v2():
|
|
1270
|
+
# current version with security improvements
|
|
1271
|
+
return jsonify({"users": get_users_v2()})
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
PHASE 13: FILE UPLOAD SECURITY
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
validate file uploads
|
|
1278
|
+
|
|
1279
|
+
import imghdr
|
|
1280
|
+
import os
|
|
1281
|
+
from pathlib import Path
|
|
1282
|
+
|
|
1283
|
+
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.pdf'}
|
|
1284
|
+
ALLOWED_MIMES = {
|
|
1285
|
+
'image/jpeg', 'image/png', 'image/gif', 'application/pdf'
|
|
1286
|
+
}
|
|
1287
|
+
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def validate_upload(file) -> tuple[bool, str]:
|
|
1291
|
+
"""Validate an uploaded file."""
|
|
1292
|
+
# check file size
|
|
1293
|
+
file.seek(0, os.SEEK_END)
|
|
1294
|
+
size = file.tell()
|
|
1295
|
+
file.seek(0)
|
|
1296
|
+
|
|
1297
|
+
if size > MAX_FILE_SIZE:
|
|
1298
|
+
return False, f"File too large (max {MAX_FILE_SIZE} bytes)"
|
|
1299
|
+
if size == 0:
|
|
1300
|
+
return False, "Empty file"
|
|
1301
|
+
|
|
1302
|
+
# check extension
|
|
1303
|
+
filename = Path(file.filename)
|
|
1304
|
+
if filename.suffix.lower() not in ALLOWED_EXTENSIONS:
|
|
1305
|
+
return False, f"Invalid file type. Allowed: {ALLOWED_EXTENSIONS}"
|
|
1306
|
+
|
|
1307
|
+
# check mime type
|
|
1308
|
+
mime = file.content_type
|
|
1309
|
+
if mime not in ALLOWED_MIMES:
|
|
1310
|
+
return False, f"Invalid content type: {mime}"
|
|
1311
|
+
|
|
1312
|
+
# for images, verify actual content
|
|
1313
|
+
if mime.startswith('image/'):
|
|
1314
|
+
header = file.read(32)
|
|
1315
|
+
file.seek(0)
|
|
1316
|
+
if not imghdr.what(None, header):
|
|
1317
|
+
return False, "Invalid image file"
|
|
1318
|
+
|
|
1319
|
+
return True, "Valid"
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
sanitize filenames
|
|
1323
|
+
|
|
1324
|
+
import re
|
|
1325
|
+
from datetime import datetime
|
|
1326
|
+
|
|
1327
|
+
def sanitize_upload_filename(filename: str) -> str:
|
|
1328
|
+
"""Generate safe filename for upload."""
|
|
1329
|
+
# extract extension
|
|
1330
|
+
parts = filename.rsplit('.', 1)
|
|
1331
|
+
if len(parts) != 2:
|
|
1332
|
+
raise ValueError("File must have extension")
|
|
1333
|
+
name, ext = parts
|
|
1334
|
+
|
|
1335
|
+
# sanitize name
|
|
1336
|
+
name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
|
|
1337
|
+
name = name[:50] # limit length
|
|
1338
|
+
|
|
1339
|
+
# add timestamp for uniqueness
|
|
1340
|
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
1341
|
+
|
|
1342
|
+
return f"{timestamp}_{name}.{ext.lower()}"
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
store outside webroot
|
|
1346
|
+
|
|
1347
|
+
def save_upload(file) -> str:
|
|
1348
|
+
"""Save uploaded file securely."""
|
|
1349
|
+
is_valid, msg = validate_upload(file)
|
|
1350
|
+
if not is_valid:
|
|
1351
|
+
raise ValueError(msg)
|
|
1352
|
+
|
|
1353
|
+
safe_name = sanitize_upload_filename(file.filename)
|
|
1354
|
+
|
|
1355
|
+
# store outside web root
|
|
1356
|
+
upload_dir = Path('/var/app/uploads')
|
|
1357
|
+
upload_dir.mkdir(mode=0o750, exist_ok=True)
|
|
1358
|
+
|
|
1359
|
+
file_path = upload_dir / safe_name
|
|
1360
|
+
file.save(str(file_path))
|
|
1361
|
+
|
|
1362
|
+
# set restrictive permissions
|
|
1363
|
+
os.chmod(file_path, 0o640)
|
|
1364
|
+
|
|
1365
|
+
# return identifier, not path
|
|
1366
|
+
return safe_name
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
serve via application
|
|
1370
|
+
|
|
1371
|
+
@app.route('/uploads/<filename>')
|
|
1372
|
+
def serve_upload(filename):
|
|
1373
|
+
"""Serve uploaded files through application (not direct access)."""
|
|
1374
|
+
# verify user has access to this file
|
|
1375
|
+
# check permissions
|
|
1376
|
+
# log access
|
|
1377
|
+
# then serve
|
|
1378
|
+
|
|
1379
|
+
file_path = Path('/var/app/uploads') / filename
|
|
1380
|
+
if not file_path.exists():
|
|
1381
|
+
return "Not found", 404
|
|
1382
|
+
|
|
1383
|
+
return send_file(file_path)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
PHASE 14: DATABASE SECURITY
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
database connection security
|
|
1390
|
+
|
|
1391
|
+
from urllib.parse import parse_qs
|
|
1392
|
+
|
|
1393
|
+
def validate_db_url(url: str) -> str:
|
|
1394
|
+
"""Ensure database URL is secure."""
|
|
1395
|
+
if not url.startswith(('postgresql://', 'mysql://')):
|
|
1396
|
+
raise ValueError("Only postgresql and mysql are supported")
|
|
1397
|
+
|
|
1398
|
+
# require ssl
|
|
1399
|
+
if 'sslmode' not in url:
|
|
1400
|
+
raise ValueError("Database connection must use SSL")
|
|
1401
|
+
|
|
1402
|
+
parsed = parse_qs(url)
|
|
1403
|
+
# verify no plaintext credentials in logs
|
|
1404
|
+
return url
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
least privilege database user
|
|
1408
|
+
|
|
1409
|
+
application should use limited database user:
|
|
1410
|
+
|
|
1411
|
+
CREATE USER kollabor_app WITH PASSWORD 'secure_password';
|
|
1412
|
+
|
|
1413
|
+
GRANT CONNECT ON DATABASE kollabor_db TO kollabor_app;
|
|
1414
|
+
|
|
1415
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO kollabor_app;
|
|
1416
|
+
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO kollabor_app;
|
|
1417
|
+
|
|
1418
|
+
-- no create, drop, alter permissions
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
query result limits
|
|
1422
|
+
|
|
1423
|
+
def paginated_query(query: str, page: int, per_page: int = 50):
|
|
1424
|
+
"""Execute query with pagination limits."""
|
|
1425
|
+
if per_page > 100:
|
|
1426
|
+
per_page = 100 # max limit
|
|
1427
|
+
if page < 1:
|
|
1428
|
+
page = 1
|
|
1429
|
+
|
|
1430
|
+
offset = (page - 1) * per_page
|
|
1431
|
+
limited_query = f"{query} LIMIT {per_page} OFFSET {offset}"
|
|
1432
|
+
|
|
1433
|
+
return db.execute(limited_query).fetchall()
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
PHASE 15: SECURITY TESTING
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
security unit tests
|
|
1440
|
+
|
|
1441
|
+
import pytest
|
|
1442
|
+
|
|
1443
|
+
def test_sql_injection_prevented():
|
|
1444
|
+
"""Test that SQL injection is prevented."""
|
|
1445
|
+
malicious_id = "1 OR 1=1; DROP TABLE users; --"
|
|
1446
|
+
|
|
1447
|
+
# should raise validation error
|
|
1448
|
+
with pytest.raises(ValueError):
|
|
1449
|
+
get_user(malicious_id)
|
|
1450
|
+
|
|
1451
|
+
# or should only find user 1
|
|
1452
|
+
result = get_user_safe(malicious_id)
|
|
1453
|
+
assert result is None or result['id'] == 1
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def test_xss_prevention():
|
|
1457
|
+
"""Test that XSS is prevented."""
|
|
1458
|
+
xss_payload = "<script>alert('XSS')</script>"
|
|
1459
|
+
|
|
1460
|
+
rendered = render_comment(xss_payload)
|
|
1461
|
+
|
|
1462
|
+
assert "<script>" not in rendered
|
|
1463
|
+
assert "<script>" in rendered
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def test_csrf_protection():
|
|
1467
|
+
"""Test CSRF token validation."""
|
|
1468
|
+
client = TestClient(app)
|
|
1469
|
+
|
|
1470
|
+
# request without token should fail
|
|
1471
|
+
response = client.post('/transfer', json={
|
|
1472
|
+
'to': 'victim',
|
|
1473
|
+
'amount': 100
|
|
1474
|
+
})
|
|
1475
|
+
assert response.status_code == 403
|
|
1476
|
+
|
|
1477
|
+
# request with invalid token should fail
|
|
1478
|
+
response = client.post('/transfer', json={
|
|
1479
|
+
'csrf_token': 'invalid',
|
|
1480
|
+
'to': 'victim',
|
|
1481
|
+
'amount': 100
|
|
1482
|
+
})
|
|
1483
|
+
assert response.status_code == 403
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def test_password_validation():
|
|
1487
|
+
"""Test password strength requirements."""
|
|
1488
|
+
weak_passwords = [
|
|
1489
|
+
'password',
|
|
1490
|
+
'Password1',
|
|
1491
|
+
'short',
|
|
1492
|
+
'noooooooonumbers',
|
|
1493
|
+
'123456789012',
|
|
1494
|
+
]
|
|
1495
|
+
|
|
1496
|
+
for pwd in weak_passwords:
|
|
1497
|
+
errors = validate_password_strength(pwd)
|
|
1498
|
+
assert len(errors) > 0, f"Should reject: {pwd}"
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
security integration tests
|
|
1502
|
+
|
|
1503
|
+
def test_authentication_flow():
|
|
1504
|
+
"""Test secure authentication flow."""
|
|
1505
|
+
client = TestClient(app)
|
|
1506
|
+
|
|
1507
|
+
# register
|
|
1508
|
+
response = client.post('/register', json={
|
|
1509
|
+
'username': 'testuser',
|
|
1510
|
+
'password': 'SecurePass123!',
|
|
1511
|
+
'email': 'test@example.com'
|
|
1512
|
+
})
|
|
1513
|
+
assert response.status_code == 201
|
|
1514
|
+
|
|
1515
|
+
# login with correct password
|
|
1516
|
+
response = client.post('/login', json={
|
|
1517
|
+
'username': 'testuser',
|
|
1518
|
+
'password': 'SecurePass123!'
|
|
1519
|
+
})
|
|
1520
|
+
assert response.status_code == 200
|
|
1521
|
+
assert 'token' in response.json()
|
|
1522
|
+
|
|
1523
|
+
# login with wrong password fails
|
|
1524
|
+
response = client.post('/login', json={
|
|
1525
|
+
'username': 'testuser',
|
|
1526
|
+
'password': 'WrongPass123!'
|
|
1527
|
+
})
|
|
1528
|
+
assert response.status_code == 401
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
penetration testing
|
|
1532
|
+
|
|
1533
|
+
run security scans:
|
|
1534
|
+
|
|
1535
|
+
<terminal>bandit -r . -f json -o bandit-report.json</terminal>
|
|
1536
|
+
|
|
1537
|
+
<terminal>safety check --json > safety-report.json</terminal>
|
|
1538
|
+
|
|
1539
|
+
<terminal>pip-audit --format json > pip-audit-report.json</terminal>
|
|
1540
|
+
|
|
1541
|
+
manual test checklist:
|
|
1542
|
+
[ ] try sql injection in all inputs
|
|
1543
|
+
[ ] try xss payloads in text fields
|
|
1544
|
+
[ ] test csrf without tokens
|
|
1545
|
+
[ ] try authentication bypass
|
|
1546
|
+
[ ] test idor (insecure direct object reference)
|
|
1547
|
+
[ ] test rate limiting
|
|
1548
|
+
[ ] try file upload exploits
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
PHASE 16: SECURITY RULES (STRICT MODE)
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
while this skill is active, these rules are MANDATORY:
|
|
1555
|
+
|
|
1556
|
+
[1] VALIDATE ALL INPUT
|
|
1557
|
+
never trust data from user, api, or database
|
|
1558
|
+
whitelist allowed values, don't blacklist bad ones
|
|
1559
|
+
|
|
1560
|
+
[2] USE PARAMETERIZED QUERIES
|
|
1561
|
+
never concatenate strings into sql
|
|
1562
|
+
always use ? placeholders or orm
|
|
1563
|
+
|
|
1564
|
+
[3] ESCAPE ALL OUTPUT
|
|
1565
|
+
html escape user content before rendering
|
|
1566
|
+
use template engines with autoescape
|
|
1567
|
+
|
|
1568
|
+
[4] HASH PASSWORDS PROPERLY
|
|
1569
|
+
use bcrypt, argon2, or scrypt
|
|
1570
|
+
never store plaintext passwords
|
|
1571
|
+
never use md5, sha1, or custom algorithms
|
|
1572
|
+
|
|
1573
|
+
[5] USE CSRF TOKENS
|
|
1574
|
+
all state-changing requests need csrf protection
|
|
1575
|
+
validate tokens on server side
|
|
1576
|
+
|
|
1577
|
+
[6] ENABLE SECURITY HEADERS
|
|
1578
|
+
csp, x-frame-options, x-content-type-options
|
|
1579
|
+
httponly and secure cookies
|
|
1580
|
+
|
|
1581
|
+
[7] NEVER EXPOSE SECRETS
|
|
1582
|
+
no api keys in source code
|
|
1583
|
+
use environment variables or secret managers
|
|
1584
|
+
add sensitive patterns to git-secrets
|
|
1585
|
+
|
|
1586
|
+
[8] IMPLEMENT RATE LIMITING
|
|
1587
|
+
protect authentication endpoints
|
|
1588
|
+
protect api endpoints
|
|
1589
|
+
log rate limit violations
|
|
1590
|
+
|
|
1591
|
+
[9] LOG SECURITY EVENTS
|
|
1592
|
+
auth failures
|
|
1593
|
+
permission denials
|
|
1594
|
+
suspicious activity
|
|
1595
|
+
|
|
1596
|
+
[10] KEEP DEPENDENCIES UPDATED
|
|
1597
|
+
run security scans regularly
|
|
1598
|
+
update vulnerable packages promptly
|
|
1599
|
+
|
|
1600
|
+
[11] DISABLE DEBUG IN PRODUCTION
|
|
1601
|
+
debug mode exposes sensitive information
|
|
1602
|
+
fail fast if debug enabled in production
|
|
1603
|
+
|
|
1604
|
+
[12] USE HTTPS EVERYWHERE
|
|
1605
|
+
redirect http to https
|
|
1606
|
+
enable hsts
|
|
1607
|
+
secure cookies only
|
|
1608
|
+
|
|
1609
|
+
[13] PRINCIPLE OF LEAST PRIVILEGE
|
|
1610
|
+
users get minimum required access
|
|
1611
|
+
services get minimum required permissions
|
|
1612
|
+
|
|
1613
|
+
[14] DEFENSE IN DEPTH
|
|
1614
|
+
multiple layers of security
|
|
1615
|
+
if one layer fails, others protect
|
|
1616
|
+
|
|
1617
|
+
[15] SECURITY BY DEFAULT
|
|
1618
|
+
secure options should be default
|
|
1619
|
+
users should have to opt-in to less secure
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
PHASE 17: SECURITY CHECKLIST
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
before deploying to production:
|
|
1626
|
+
|
|
1627
|
+
input validation:
|
|
1628
|
+
[ ] all user input validated
|
|
1629
|
+
[ ] length limits enforced
|
|
1630
|
+
[ ] type checking implemented
|
|
1631
|
+
[ ] whitelist patterns used
|
|
1632
|
+
[ ] file uploads validated
|
|
1633
|
+
|
|
1634
|
+
authentication:
|
|
1635
|
+
[ ] passwords hashed with bcrypt/argon2
|
|
1636
|
+
[ ] session management secure
|
|
1637
|
+
[ ] csrf tokens implemented
|
|
1638
|
+
[ ] rate limiting on auth endpoints
|
|
1639
|
+
[ ] secure password requirements
|
|
1640
|
+
|
|
1641
|
+
authorization:
|
|
1642
|
+
[ ] access control on all endpoints
|
|
1643
|
+
[ ] ownership checks for resources
|
|
1644
|
+
[ ] role-based permissions
|
|
1645
|
+
[ ] admin actions protected
|
|
1646
|
+
|
|
1647
|
+
data protection:
|
|
1648
|
+
[ ] secrets in environment variables
|
|
1649
|
+
[ ] sensitive data encrypted at rest
|
|
1650
|
+
[ ] tls for data in transit
|
|
1651
|
+
[ ] database connections use ssl
|
|
1652
|
+
[ ] api keys stored hashed
|
|
1653
|
+
|
|
1654
|
+
output encoding:
|
|
1655
|
+
[ ] html escaping enabled
|
|
1656
|
+
[ ] template autoescape on
|
|
1657
|
+
[ ] json content-type headers
|
|
1658
|
+
[ ] csp headers configured
|
|
1659
|
+
|
|
1660
|
+
logging:
|
|
1661
|
+
[ ] security events logged
|
|
1662
|
+
[ ] sensitive data redacted
|
|
1663
|
+
[ ] log injection prevented
|
|
1664
|
+
[ ] log rotation configured
|
|
1665
|
+
|
|
1666
|
+
dependencies:
|
|
1667
|
+
[ ] no known vulnerabilities
|
|
1668
|
+
[ ] dependencies pinned
|
|
1669
|
+
[ ] security scanning in ci
|
|
1670
|
+
[ ] update process defined
|
|
1671
|
+
|
|
1672
|
+
infrastructure:
|
|
1673
|
+
[ ] https enforced
|
|
1674
|
+
[ ] hsts enabled
|
|
1675
|
+
[ ] security headers configured
|
|
1676
|
+
[ ] debug mode disabled
|
|
1677
|
+
[ ] backups encrypted
|
|
1678
|
+
|
|
1679
|
+
monitoring:
|
|
1680
|
+
[ ] failed auth alerts
|
|
1681
|
+
[ ] rate limit alerts
|
|
1682
|
+
[ ] anomaly detection
|
|
1683
|
+
[ ] incident response plan
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
FINAL REMINDERS
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
security is a process, not a feature
|
|
1690
|
+
|
|
1691
|
+
it starts at design.
|
|
1692
|
+
it continues through development.
|
|
1693
|
+
it doesn't end at deployment.
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
there is no secure software
|
|
1697
|
+
|
|
1698
|
+
there is only software that has been:
|
|
1699
|
+
- analyzed for vulnerabilities
|
|
1700
|
+
- tested against attacks
|
|
1701
|
+
- monitored for intrusions
|
|
1702
|
+
- updated when issues found
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
the attacker only needs to be right once
|
|
1706
|
+
|
|
1707
|
+
you need to be right every time.
|
|
1708
|
+
defense in depth is essential.
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
when in doubt
|
|
1712
|
+
|
|
1713
|
+
err on the side of security.
|
|
1714
|
+
validate one more time.
|
|
1715
|
+
add one more layer of protection.
|
|
1716
|
+
log one more event.
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
your responsibility
|
|
1720
|
+
|
|
1721
|
+
every line of code you write could introduce a vulnerability.
|
|
1722
|
+
every feature you build needs security consideration.
|
|
1723
|
+
every deployment needs security validation.
|
|
1724
|
+
|
|
1725
|
+
users trust you with their data.
|
|
1726
|
+
their privacy.
|
|
1727
|
+
their security.
|
|
1728
|
+
|
|
1729
|
+
don't betray that trust.
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
now go write secure code.
|