python-skills 1.0.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.
- python_skills/__init__.py +10 -0
- python_skills/__main__.py +6 -0
- python_skills/adapters/__init__.py +48 -0
- python_skills/adapters/agent_skills.py +415 -0
- python_skills/adapters/aider_adapter.py +226 -0
- python_skills/adapters/base.py +153 -0
- python_skills/adapters/claude.py +474 -0
- python_skills/adapters/cline.py +332 -0
- python_skills/adapters/codex.py +24 -0
- python_skills/adapters/continue_adapter.py +198 -0
- python_skills/adapters/cursor.py +327 -0
- python_skills/adapters/gemini.py +26 -0
- python_skills/adapters/goose.py +26 -0
- python_skills/adapters/junie.py +25 -0
- python_skills/adapters/kiro.py +382 -0
- python_skills/adapters/opencode.py +27 -0
- python_skills/adapters/roo.py +25 -0
- python_skills/adapters/universal.py +203 -0
- python_skills/adapters/vscode.py +27 -0
- python_skills/adapters/windsurf.py +26 -0
- python_skills/adapters/zed.py +27 -0
- python_skills/cli.py +326 -0
- python_skills/config.py +160 -0
- python_skills/detector.py +152 -0
- python_skills/installer.py +163 -0
- python_skills/markers.py +115 -0
- python_skills/skills/__init__.py +14 -0
- python_skills/skills/loader.py +171 -0
- python_skills/skills/metadata.py +152 -0
- python_skills/skills/registry.py +101 -0
- python_skills/state.py +204 -0
- python_skills-1.0.0.dist-info/METADATA +99 -0
- python_skills-1.0.0.dist-info/RECORD +105 -0
- python_skills-1.0.0.dist-info/WHEEL +4 -0
- python_skills-1.0.0.dist-info/entry_points.txt +2 -0
- python_skills-1.0.0.dist-info/licenses/LICENSE +21 -0
- skills/advanced_python.md +239 -0
- skills/anti_patterns/index.md +406 -0
- skills/comprehensions.md +167 -0
- skills/control_flow.md +175 -0
- skills/data_structures.md +243 -0
- skills/debugging/common_bugs.md +222 -0
- skills/debugging/inspection_techniques.md +249 -0
- skills/debugging/root_cause.md +203 -0
- skills/engineering/application_logging.md +195 -0
- skills/engineering/cli_apps.md +207 -0
- skills/engineering/configuration.md +218 -0
- skills/engineering/database.md +240 -0
- skills/engineering/dependency_management.md +205 -0
- skills/engineering/http_clients.md +267 -0
- skills/engineering/modules_packages.md +211 -0
- skills/engineering/packaging.md +197 -0
- skills/engineering/project_structure.md +155 -0
- skills/engineering/pyproject_toml.md +302 -0
- skills/engineering/virtual_environments.md +206 -0
- skills/functions.md +244 -0
- skills/generation/async_concurrency.md +291 -0
- skills/generation/error_handling.md +276 -0
- skills/generation/protocols_generics.md +243 -0
- skills/generation/type_hints.md +290 -0
- skills/generation/validation_pipeline.md +274 -0
- skills/generation/workflow.md +190 -0
- skills/oop.md +228 -0
- skills/quality/abstractions.md +154 -0
- skills/quality/comments.md +177 -0
- skills/quality/documentation.md +176 -0
- skills/quality/duplication.md +137 -0
- skills/quality/maintainability.md +142 -0
- skills/quality/naming.md +171 -0
- skills/quality/quality_functions.md +245 -0
- skills/quality/readability.md +239 -0
- skills/quality/type_annotations.md +192 -0
- skills/refactoring/behavior_preservation.md +157 -0
- skills/refactoring/incremental.md +187 -0
- skills/refactoring/interface_stability.md +199 -0
- skills/refactoring/safe_refactoring.md +206 -0
- skills/security/auth_boundaries.md +200 -0
- skills/security/command_injection.md +207 -0
- skills/security/dependency_risks.md +282 -0
- skills/security/file_handling.md +156 -0
- skills/security/input_validation.md +190 -0
- skills/security/path_traversal.md +172 -0
- skills/security/secrets.md +171 -0
- skills/security/sql_injection.md +188 -0
- skills/security/unsafe_deserialization.md +164 -0
- skills/stdlib/argparse.md +178 -0
- skills/stdlib/collections.md +212 -0
- skills/stdlib/datetime.md +187 -0
- skills/stdlib/functools.md +238 -0
- skills/stdlib/itertools.md +183 -0
- skills/stdlib/json.md +162 -0
- skills/stdlib/logging.md +185 -0
- skills/stdlib/os_sys.md +184 -0
- skills/stdlib/pathlib.md +218 -0
- skills/stdlib/re.md +171 -0
- skills/stdlib/statistics.md +112 -0
- skills/stdlib/subprocess.md +211 -0
- skills/testing/async_tests.md +249 -0
- skills/testing/coverage.md +168 -0
- skills/testing/edge_cases.md +197 -0
- skills/testing/fixtures_mocks.md +203 -0
- skills/testing/organization.md +205 -0
- skills/testing/parameterized.md +174 -0
- skills/testing/regression_tests.md +165 -0
- skills/variables_types.md +107 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: security_input_validation
|
|
3
|
+
purpose: Validate all external input at system boundaries
|
|
4
|
+
category: security
|
|
5
|
+
triggers:
|
|
6
|
+
- validation
|
|
7
|
+
- input
|
|
8
|
+
- pydantic
|
|
9
|
+
- boundary
|
|
10
|
+
- sanitize
|
|
11
|
+
- allowlist
|
|
12
|
+
dependencies:
|
|
13
|
+
- security/sql_injection.md
|
|
14
|
+
- security/command_injection.md
|
|
15
|
+
- security/path_traversal.md
|
|
16
|
+
- security/unsafe_deserialization.md
|
|
17
|
+
- generation/error_handling.md
|
|
18
|
+
priority: supporting
|
|
19
|
+
estimated_tokens: 1700
|
|
20
|
+
---
|
|
21
|
+
# Security: Input Validation
|
|
22
|
+
|
|
23
|
+
**Purpose**: Validate all external input at system boundaries.
|
|
24
|
+
|
|
25
|
+
**When to use**: All code handling user input, API requests, file uploads, config.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Core Rules
|
|
30
|
+
|
|
31
|
+
### Validate at Boundaries
|
|
32
|
+
```python
|
|
33
|
+
# API boundary
|
|
34
|
+
@app.post("/users")
|
|
35
|
+
def create_user(request: CreateUserRequest) -> UserResponse:
|
|
36
|
+
# Request validated by Pydantic/FastAPI automatically
|
|
37
|
+
return user_service.create(request)
|
|
38
|
+
|
|
39
|
+
# CLI boundary
|
|
40
|
+
def main(args: list[str]) -> int:
|
|
41
|
+
parsed = parse_args(args) # argparse validates
|
|
42
|
+
config = load_config(parsed.config_file) # Validates file
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
# Config boundary
|
|
46
|
+
def load_config(path: Path) -> Config:
|
|
47
|
+
raw = tomllib.load(path)
|
|
48
|
+
return Config.model_validate(raw) # Pydantic validates
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Validation Libraries
|
|
52
|
+
| Use Case | Library |
|
|
53
|
+
|----------|---------|
|
|
54
|
+
| API/Config/Data | `pydantic` (v2) |
|
|
55
|
+
| Simple CLI | `argparse` + custom types |
|
|
56
|
+
| JSON Schema | `jsonschema` |
|
|
57
|
+
| Form data | `wtforms` |
|
|
58
|
+
|
|
59
|
+
### Pydantic Patterns
|
|
60
|
+
```python
|
|
61
|
+
from pydantic import BaseModel, Field, field_validator, EmailStr
|
|
62
|
+
from typing import Annotated
|
|
63
|
+
|
|
64
|
+
class CreateUserRequest(BaseModel):
|
|
65
|
+
email: EmailStr
|
|
66
|
+
name: Annotated[str, Field(min_length=2, max_length=100)]
|
|
67
|
+
age: Annotated[int, Field(ge=13, le=120)]
|
|
68
|
+
tags: list[str] = Field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
@field_validator("name")
|
|
71
|
+
@classmethod
|
|
72
|
+
def name_no_special_chars(cls, v: str) -> str:
|
|
73
|
+
if not v.replace(" ", "").isalnum():
|
|
74
|
+
raise ValueError("Name must be alphanumeric")
|
|
75
|
+
return v.strip()
|
|
76
|
+
|
|
77
|
+
# Usage
|
|
78
|
+
def create_user(data: CreateUserRequest) -> User:
|
|
79
|
+
# data is guaranteed valid here
|
|
80
|
+
...
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Allowlist Over Blocklist
|
|
84
|
+
```python
|
|
85
|
+
# GOOD — allowlist
|
|
86
|
+
ALLOWED_EXTENSIONS = {".jpg", ".png", ".pdf"}
|
|
87
|
+
def validate_extension(filename: str) -> None:
|
|
88
|
+
ext = Path(filename).suffix.lower()
|
|
89
|
+
if ext not in ALLOWED_EXTENSIONS:
|
|
90
|
+
raise ValidationError("file", f"Extension not allowed: {ext}")
|
|
91
|
+
|
|
92
|
+
# BAD — blocklist (incomplete)
|
|
93
|
+
def validate_extension(filename: str) -> None:
|
|
94
|
+
if filename.endswith(".exe"):
|
|
95
|
+
raise ValidationError("file", "Executable not allowed")
|
|
96
|
+
# Misses .bat, .sh, .php, .jar, etc.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Size Limits
|
|
100
|
+
```python
|
|
101
|
+
# Request body
|
|
102
|
+
app = FastAPI()
|
|
103
|
+
app.add_middleware(MaxBodySizeMiddleware, max_size=10_000_000) # 10MB
|
|
104
|
+
|
|
105
|
+
# File upload
|
|
106
|
+
def upload(file: UploadFile) -> None:
|
|
107
|
+
if file.size > MAX_FILE_SIZE:
|
|
108
|
+
raise ValidationError("file", "File too large")
|
|
109
|
+
|
|
110
|
+
# Stream, don't load entirely
|
|
111
|
+
while chunk := file.read(8192):
|
|
112
|
+
process(chunk)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Sanitization
|
|
116
|
+
```python
|
|
117
|
+
# HTML output
|
|
118
|
+
from markupsafe import escape
|
|
119
|
+
safe_html = escape(user_input)
|
|
120
|
+
|
|
121
|
+
# SQL — use parameterized queries (see sql_injection.md)
|
|
122
|
+
# Path — use pathlib.resolve() + is_relative_to() (see path_traversal.md)
|
|
123
|
+
# Shell — never use shell=True (see command_injection.md)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Decision Rules
|
|
129
|
+
|
|
130
|
+
| Input Type | Validation |
|
|
131
|
+
|------------|------------|
|
|
132
|
+
| API request | Pydantic model |
|
|
133
|
+
| Config file | Pydantic model |
|
|
134
|
+
| CLI args | argparse + custom types |
|
|
135
|
+
| File upload | Size + type + content validation |
|
|
136
|
+
| Database input | ORM/parameterized queries |
|
|
137
|
+
| User content (display) | Escape on output |
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Preferred Patterns
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
# Centralized validation
|
|
145
|
+
class ValidationError(Exception):
|
|
146
|
+
def __init__(self, field: str, message: str):
|
|
147
|
+
self.field = field
|
|
148
|
+
self.message = message
|
|
149
|
+
super().__init__(f"{field}: {message}")
|
|
150
|
+
|
|
151
|
+
def validate_and_load[T](
|
|
152
|
+
model: type[BaseModel],
|
|
153
|
+
data: dict,
|
|
154
|
+
context: dict | None = None,
|
|
155
|
+
) -> T:
|
|
156
|
+
try:
|
|
157
|
+
return model.model_validate(data, context=context)
|
|
158
|
+
except ValidationError as e:
|
|
159
|
+
# Convert to domain exceptions
|
|
160
|
+
errors = e.errors()
|
|
161
|
+
raise ValidationError(errors[0]["loc"][0], errors[0]["msg"]) from e
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Avoid
|
|
167
|
+
|
|
168
|
+
- Trusting any external input
|
|
169
|
+
- Validation only in UI (client-side only)
|
|
170
|
+
- Blocklist-based validation
|
|
171
|
+
- No size limits on uploads/requests
|
|
172
|
+
- Manual string parsing for structured data
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Validation Considerations
|
|
177
|
+
|
|
178
|
+
- Test with malicious inputs (injection, oversized, malformed)
|
|
179
|
+
- Fuzz testing for parsers
|
|
180
|
+
- Validate error messages don't leak info
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Related Skills
|
|
185
|
+
|
|
186
|
+
- `security/sql_injection.md`
|
|
187
|
+
- `security/command_injection.md`
|
|
188
|
+
- `security/path_traversal.md`
|
|
189
|
+
- `security/unsafe_deserialization.md`
|
|
190
|
+
- `generation/error_handling.md`
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Security: Path Traversal Prevention
|
|
2
|
+
|
|
3
|
+
**Purpose**: Prevent directory traversal attacks when handling file paths.
|
|
4
|
+
|
|
5
|
+
**When to use**: File uploads, path parameters, file serving, archive extraction.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Core Rules
|
|
10
|
+
|
|
11
|
+
### Always Resolve and Validate
|
|
12
|
+
```python
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
def safe_path(user_input: str, base_dir: Path) -> Path:
|
|
16
|
+
"""Resolve path and ensure it's within base directory."""
|
|
17
|
+
base = base_dir.resolve()
|
|
18
|
+
requested = (base / user_input).resolve()
|
|
19
|
+
|
|
20
|
+
# Critical: check AFTER resolve (handles symlinks)
|
|
21
|
+
if not requested.is_relative_to(base):
|
|
22
|
+
raise ValueError(f"Path traversal attempt: {user_input}")
|
|
23
|
+
|
|
24
|
+
return requested
|
|
25
|
+
|
|
26
|
+
# Usage
|
|
27
|
+
@app.get("/files/{path:path}")
|
|
28
|
+
def serve_file(path: str):
|
|
29
|
+
safe = safe_path(path, FILES_ROOT)
|
|
30
|
+
return FileResponse(safe)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Common Attack Vectors
|
|
34
|
+
```python
|
|
35
|
+
# These all attempt to escape:
|
|
36
|
+
"../../etc/passwd"
|
|
37
|
+
"..\\..\\windows\\system32"
|
|
38
|
+
"%2e%2e%2f" # URL encoded
|
|
39
|
+
"....//" # Double dots
|
|
40
|
+
"subdir/../../etc/passwd"
|
|
41
|
+
"symlink_to_root/target"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Archive Extraction (Critical)
|
|
45
|
+
```python
|
|
46
|
+
import tarfile
|
|
47
|
+
import zipfile
|
|
48
|
+
|
|
49
|
+
def safe_extract_tar(tar_path: Path, dest: Path) -> None:
|
|
50
|
+
dest = dest.resolve()
|
|
51
|
+
with tarfile.open(tar_path) as tar:
|
|
52
|
+
for member in tar.getmembers():
|
|
53
|
+
member_path = (dest / member.name).resolve()
|
|
54
|
+
if not member_path.is_relative_to(dest):
|
|
55
|
+
raise ValueError(f"Path traversal in archive: {member.name}")
|
|
56
|
+
tar.extractall(dest) # Safe after validation
|
|
57
|
+
|
|
58
|
+
def safe_extract_zip(zip_path: Path, dest: Path) -> None:
|
|
59
|
+
dest = dest.resolve()
|
|
60
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
61
|
+
for name in zf.namelist():
|
|
62
|
+
member_path = (dest / name).resolve()
|
|
63
|
+
if not member_path.is_relative_to(dest):
|
|
64
|
+
raise ValueError(f"Path traversal in archive: {name}")
|
|
65
|
+
zf.extractall(dest)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### File Upload
|
|
69
|
+
```python
|
|
70
|
+
def save_upload(file: UploadFile, upload_dir: Path) -> Path:
|
|
71
|
+
# Validate filename
|
|
72
|
+
filename = Path(file.filename).name # Strip directory components
|
|
73
|
+
if not filename:
|
|
74
|
+
raise ValueError("Invalid filename")
|
|
75
|
+
|
|
76
|
+
# Allowlist extension
|
|
77
|
+
allowed = {".jpg", ".png", ".pdf", ".txt"}
|
|
78
|
+
if filename.suffix.lower() not in allowed:
|
|
79
|
+
raise ValueError("File type not allowed")
|
|
80
|
+
|
|
81
|
+
# Generate safe name (UUID + extension)
|
|
82
|
+
safe_name = f"{uuid4()}{filename.suffix.lower()}"
|
|
83
|
+
dest = safe_path(safe_name, upload_dir)
|
|
84
|
+
|
|
85
|
+
# Stream write (memory efficient)
|
|
86
|
+
with dest.open("wb") as f:
|
|
87
|
+
while chunk := file.file.read(8192):
|
|
88
|
+
f.write(chunk)
|
|
89
|
+
|
|
90
|
+
return dest
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Symlink Safety
|
|
94
|
+
```python
|
|
95
|
+
def read_file_safe(path: Path, base: Path) -> bytes:
|
|
96
|
+
"""Read file, following symlinks but validating final target."""
|
|
97
|
+
resolved = path.resolve()
|
|
98
|
+
base_resolved = base.resolve()
|
|
99
|
+
|
|
100
|
+
if not resolved.is_relative_to(base_resolved):
|
|
101
|
+
raise ValueError("Path traversal via symlink")
|
|
102
|
+
|
|
103
|
+
# Optional: reject symlinks entirely
|
|
104
|
+
if path.is_symlink():
|
|
105
|
+
raise ValueError("Symlinks not allowed")
|
|
106
|
+
|
|
107
|
+
return resolved.read_bytes()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Decision Rules
|
|
113
|
+
|
|
114
|
+
| Operation | Protection |
|
|
115
|
+
|-----------|------------|
|
|
116
|
+
| Serve static file | `safe_path` + `is_relative_to` |
|
|
117
|
+
| User upload | Strip path, allowlist ext, UUID name |
|
|
118
|
+
| Archive extract | Validate each member before extract |
|
|
119
|
+
| Config file read | Fixed known paths only |
|
|
120
|
+
| Temp file | `tempfile.mkstemp` (secure) |
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Preferred Patterns
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# Centralized path validator
|
|
128
|
+
class PathValidator:
|
|
129
|
+
def __init__(self, base: Path):
|
|
130
|
+
self.base = base.resolve()
|
|
131
|
+
|
|
132
|
+
def validate(self, user_path: str | Path) -> Path:
|
|
133
|
+
requested = (self.base / user_path).resolve()
|
|
134
|
+
if not requested.is_relative_to(self.base):
|
|
135
|
+
raise SecurityError("Path traversal attempt")
|
|
136
|
+
return requested
|
|
137
|
+
|
|
138
|
+
def validate_exists(self, user_path: str | Path) -> Path:
|
|
139
|
+
path = self.validate(user_path)
|
|
140
|
+
if not path.exists():
|
|
141
|
+
raise FileNotFoundError(path)
|
|
142
|
+
return path
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Avoid
|
|
148
|
+
|
|
149
|
+
- `os.path.join` without validation
|
|
150
|
+
- `Path(user_input)` directly
|
|
151
|
+
- `../` in user-controlled paths
|
|
152
|
+
- Extracting archives without member validation
|
|
153
|
+
- Serving files from user input without validation
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Validation Considerations
|
|
158
|
+
|
|
159
|
+
- Test with traversal payloads
|
|
160
|
+
- Test with symlinks
|
|
161
|
+
- Test with URL encoding
|
|
162
|
+
- Test with null bytes (`\0`)
|
|
163
|
+
- `bandit` B108, B306 checks
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Related Skills
|
|
168
|
+
|
|
169
|
+
- `security/command_injection.md`
|
|
170
|
+
- `security/input_validation.md`
|
|
171
|
+
- `stdlib/pathlib.md`
|
|
172
|
+
- `stdlib/subprocess.md`
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Security: Secrets Management
|
|
2
|
+
|
|
3
|
+
**Purpose**: Handle secrets (API keys, passwords, tokens) securely.
|
|
4
|
+
|
|
5
|
+
**When to use**: Any code dealing with credentials or sensitive data.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Core Rules
|
|
10
|
+
|
|
11
|
+
### Never Hardcode Secrets
|
|
12
|
+
```python
|
|
13
|
+
# NEVER
|
|
14
|
+
API_KEY = "sk_live_abc123"
|
|
15
|
+
DB_PASSWORD = "supersecret"
|
|
16
|
+
|
|
17
|
+
# NEVER in config files committed to git
|
|
18
|
+
# config.yaml
|
|
19
|
+
# database:
|
|
20
|
+
# password: "supersecret"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Environment Variables
|
|
24
|
+
```python
|
|
25
|
+
import os
|
|
26
|
+
from pydantic_settings import BaseSettings
|
|
27
|
+
|
|
28
|
+
class Settings(BaseSettings):
|
|
29
|
+
database_url: str
|
|
30
|
+
api_key: str
|
|
31
|
+
jwt_secret: str
|
|
32
|
+
|
|
33
|
+
model_config = SettingsConfigDict(
|
|
34
|
+
env_file=".env",
|
|
35
|
+
env_file_encoding="utf-8",
|
|
36
|
+
extra="ignore",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
settings = Settings() # Loads from env vars + .env
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### .env File Pattern
|
|
43
|
+
```bash
|
|
44
|
+
# .env.example (COMMITTED)
|
|
45
|
+
DATABASE_URL=postgresql://user:pass@localhost/db
|
|
46
|
+
API_KEY=
|
|
47
|
+
JWT_SECRET=
|
|
48
|
+
|
|
49
|
+
# .env (GITIGNORED - local only)
|
|
50
|
+
DATABASE_URL=postgresql://prod:realpass@db/prod
|
|
51
|
+
API_KEY=sk_live_realkey
|
|
52
|
+
JWT_SECRET=supersecretkey
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Secret Managers (Production)
|
|
56
|
+
```python
|
|
57
|
+
# AWS Secrets Manager
|
|
58
|
+
import boto3
|
|
59
|
+
|
|
60
|
+
def get_secret(name: str) -> str:
|
|
61
|
+
client = boto3.client("secretsmanager")
|
|
62
|
+
response = client.get_secret_value(SecretId=name)
|
|
63
|
+
return response["SecretString"]
|
|
64
|
+
|
|
65
|
+
# HashiCorp Vault
|
|
66
|
+
import hvac
|
|
67
|
+
|
|
68
|
+
client = hvac.Client(url="https://vault.example.com")
|
|
69
|
+
secret = client.secrets.kv.v2.read_secret_version(path="app/config")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### In Memory Only
|
|
73
|
+
```python
|
|
74
|
+
# Don't log secrets
|
|
75
|
+
logger.info("Connecting to database", extra={"url": sanitize_url(db_url)})
|
|
76
|
+
|
|
77
|
+
def sanitize_url(url: str) -> str:
|
|
78
|
+
# postgresql://user:pass@host/db -> postgresql://user:***@host/db
|
|
79
|
+
from urllib.parse import urlparse, urlunparse
|
|
80
|
+
parsed = urlparse(url)
|
|
81
|
+
if parsed.password:
|
|
82
|
+
netloc = f"{parsed.username}:***@{parsed.hostname}"
|
|
83
|
+
if parsed.port:
|
|
84
|
+
netloc += f":{parsed.port}"
|
|
85
|
+
return urlunparse(parsed._replace(netloc=netloc))
|
|
86
|
+
return url
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Key Rotation
|
|
90
|
+
```python
|
|
91
|
+
# Support multiple keys for rotation
|
|
92
|
+
class Settings(BaseSettings):
|
|
93
|
+
jwt_secrets: list[str] = Field(default_factory=list) # Current + previous
|
|
94
|
+
current_jwt_secret_index: int = 0
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def jwt_secret(self) -> str:
|
|
98
|
+
return self.jwt_secrets[self.current_jwt_secret_index]
|
|
99
|
+
|
|
100
|
+
def verify_token(self, token: str) -> dict:
|
|
101
|
+
# Try all secrets for backward compatibility
|
|
102
|
+
for secret in self.jwt_secrets:
|
|
103
|
+
try:
|
|
104
|
+
return jwt.decode(token, secret, algorithms=["HS256"])
|
|
105
|
+
except jwt.InvalidTokenError:
|
|
106
|
+
continue
|
|
107
|
+
raise InvalidTokenError()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Decision Rules
|
|
113
|
+
|
|
114
|
+
| Environment | Approach |
|
|
115
|
+
|-------------|----------|
|
|
116
|
+
| Local dev | `.env` file (gitignored) |
|
|
117
|
+
| CI/CD | Platform secrets (GitHub Actions, GitLab CI) |
|
|
118
|
+
| Kubernetes | Secrets + External Secrets Operator |
|
|
119
|
+
| Cloud | AWS Secrets Manager, GCP Secret Manager, Azure Key Vault |
|
|
120
|
+
| Serverless | Platform env vars + secret manager |
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Preferred Patterns
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# Single settings instance
|
|
128
|
+
_settings: Settings | None = None
|
|
129
|
+
|
|
130
|
+
def get_settings() -> Settings:
|
|
131
|
+
global _settings
|
|
132
|
+
if _settings is None:
|
|
133
|
+
_settings = Settings()
|
|
134
|
+
return _settings
|
|
135
|
+
|
|
136
|
+
# Dependency injection for testing
|
|
137
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
138
|
+
settings = settings or get_settings()
|
|
139
|
+
app = FastAPI()
|
|
140
|
+
app.state.settings = settings
|
|
141
|
+
return app
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Avoid
|
|
147
|
+
|
|
148
|
+
- Committing `.env` with real secrets
|
|
149
|
+
- Printing/logging secrets
|
|
150
|
+
- Passing secrets in URLs (query params, paths)
|
|
151
|
+
- Storing secrets in code (even encoded)
|
|
152
|
+
- Single key without rotation plan
|
|
153
|
+
- Secrets in Docker images
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Validation Considerations
|
|
158
|
+
|
|
159
|
+
- `git-secrets` / `truffleHog` / `gitleaks` in CI
|
|
160
|
+
- No secrets in logs (test with log capture)
|
|
161
|
+
- Secret rotation tested
|
|
162
|
+
- Least privilege for secret access
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Related Skills
|
|
167
|
+
|
|
168
|
+
- `security/input_validation.md`
|
|
169
|
+
- `engineering/configuration.md`
|
|
170
|
+
- `engineering/logging.md`
|
|
171
|
+
- `engineering/virtual_environments.md`
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Security: SQL Injection Prevention
|
|
2
|
+
|
|
3
|
+
**Purpose**: Prevent SQL injection through proper query parameterization.
|
|
4
|
+
|
|
5
|
+
**When to use**: All database interactions.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Core Rules
|
|
10
|
+
|
|
11
|
+
### Always Use Parameterized Queries
|
|
12
|
+
```python
|
|
13
|
+
# CORRECT — asyncpg
|
|
14
|
+
async with pool.acquire() as conn:
|
|
15
|
+
await conn.execute(
|
|
16
|
+
"INSERT INTO users (email, name) VALUES ($1, $2)",
|
|
17
|
+
email, name
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# CORRECT — psycopg
|
|
21
|
+
with conn.cursor() as cur:
|
|
22
|
+
cur.execute(
|
|
23
|
+
"INSERT INTO users (email, name) VALUES (%s, %s)",
|
|
24
|
+
(email, name)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# CORRECT — SQLAlchemy
|
|
28
|
+
session.execute(
|
|
29
|
+
text("SELECT * FROM users WHERE email = :email"),
|
|
30
|
+
{"email": email}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# CORRECT — ORM
|
|
34
|
+
User.query.filter_by(email=email).first()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Never Use String Interpolation
|
|
38
|
+
```python
|
|
39
|
+
# WRONG — SQL INJECTION!
|
|
40
|
+
cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")
|
|
41
|
+
cursor.execute("SELECT * FROM users WHERE email = '" + email + "'")
|
|
42
|
+
cursor.execute("SELECT * FROM users WHERE id = " + str(user_id))
|
|
43
|
+
|
|
44
|
+
# WRONG — .format()
|
|
45
|
+
cursor.execute("SELECT * FROM users WHERE email = '{}'".format(email))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Dynamic Queries (Safe Patterns)
|
|
49
|
+
```python
|
|
50
|
+
# WHERE IN with variable length
|
|
51
|
+
ids = [1, 2, 3]
|
|
52
|
+
placeholders = ", ".join(["%s"] * len(ids))
|
|
53
|
+
query = f"SELECT * FROM users WHERE id IN ({placeholders})"
|
|
54
|
+
cursor.execute(query, ids) # Parameters still parameterized!
|
|
55
|
+
|
|
56
|
+
# Optional filters
|
|
57
|
+
conditions = []
|
|
58
|
+
params = []
|
|
59
|
+
if name:
|
|
60
|
+
conditions.append("name ILIKE %s")
|
|
61
|
+
params.append(f"%{name}%")
|
|
62
|
+
if email:
|
|
63
|
+
conditions.append("email = %s")
|
|
64
|
+
params.append(email)
|
|
65
|
+
|
|
66
|
+
query = "SELECT * FROM users"
|
|
67
|
+
if conditions:
|
|
68
|
+
query += " WHERE " + " AND ".join(conditions)
|
|
69
|
+
cursor.execute(query, params)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### ORM Safety
|
|
73
|
+
```python
|
|
74
|
+
# SQLAlchemy — safe
|
|
75
|
+
User.query.filter(User.email == email).all()
|
|
76
|
+
session.query(User).filter_by(email=email).first()
|
|
77
|
+
|
|
78
|
+
# Django — safe
|
|
79
|
+
User.objects.filter(email=email)
|
|
80
|
+
|
|
81
|
+
# Unsafe in ORM — raw()
|
|
82
|
+
User.objects.raw("SELECT * FROM users WHERE email = '%s'" % email) # BAD!
|
|
83
|
+
User.objects.raw("SELECT * FROM users WHERE email = %s", [email]) # GOOD
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Identifier Quoting (Table/Column Names)
|
|
87
|
+
```python
|
|
88
|
+
# Can't parameterize identifiers — validate against allowlist
|
|
89
|
+
ALLOWED_TABLES = {"users", "orders", "products"}
|
|
90
|
+
ALLOWED_COLUMNS = {"id", "email", "name", "created_at"}
|
|
91
|
+
|
|
92
|
+
def query_table(table: str, column: str, value: str):
|
|
93
|
+
if table not in ALLOWED_TABLES:
|
|
94
|
+
raise ValueError("Invalid table")
|
|
95
|
+
if column not in ALLOWED_COLUMNS:
|
|
96
|
+
raise ValueError("Invalid column")
|
|
97
|
+
|
|
98
|
+
# Safe: validated identifiers
|
|
99
|
+
query = f"SELECT * FROM {table} WHERE {column} = %s"
|
|
100
|
+
cursor.execute(query, (value,))
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Decision Rules
|
|
106
|
+
|
|
107
|
+
| Query Type | Safe Pattern |
|
|
108
|
+
|------------|--------------|
|
|
109
|
+
| Static query | Parameterized |
|
|
110
|
+
| Dynamic WHERE | Build conditions, parameterize values |
|
|
111
|
+
| IN clause | Generate placeholders, parameterize values |
|
|
112
|
+
| Table/column names | Allowlist validation |
|
|
113
|
+
| Complex reporting | Views / stored procedures / CTEs |
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Preferred Patterns
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
# Repository pattern encapsulates safety
|
|
121
|
+
class UserRepository:
|
|
122
|
+
def __init__(self, pool: asyncpg.Pool):
|
|
123
|
+
self.pool = pool
|
|
124
|
+
|
|
125
|
+
async def find_by_email(self, email: str) -> User | None:
|
|
126
|
+
async with self.pool.acquire() as conn:
|
|
127
|
+
row = await conn.fetchrow(
|
|
128
|
+
"SELECT * FROM users WHERE email = $1",
|
|
129
|
+
email
|
|
130
|
+
)
|
|
131
|
+
return User(**row) if row else None
|
|
132
|
+
|
|
133
|
+
async def search(
|
|
134
|
+
self,
|
|
135
|
+
name: str | None = None,
|
|
136
|
+
email: str | None = None,
|
|
137
|
+
limit: int = 100,
|
|
138
|
+
) -> list[User]:
|
|
139
|
+
conditions = []
|
|
140
|
+
params = []
|
|
141
|
+
param_num = 1
|
|
142
|
+
|
|
143
|
+
if name:
|
|
144
|
+
conditions.append(f"name ILIKE ${param_num}")
|
|
145
|
+
params.append(f"%{name}%")
|
|
146
|
+
param_num += 1
|
|
147
|
+
if email:
|
|
148
|
+
conditions.append(f"email = ${param_num}")
|
|
149
|
+
params.append(email)
|
|
150
|
+
param_num += 1
|
|
151
|
+
|
|
152
|
+
query = "SELECT * FROM users"
|
|
153
|
+
if conditions:
|
|
154
|
+
query += " WHERE " + " AND ".join(conditions)
|
|
155
|
+
query += f" LIMIT ${param_num}"
|
|
156
|
+
params.append(limit)
|
|
157
|
+
|
|
158
|
+
async with self.pool.acquire() as conn:
|
|
159
|
+
rows = await conn.fetch(query, *params)
|
|
160
|
+
return [User(**row) for row in rows]
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Avoid
|
|
166
|
+
|
|
167
|
+
- Any string concatenation in SQL
|
|
168
|
+
- `f-strings` in SQL
|
|
169
|
+
- `.format()` in SQL
|
|
170
|
+
- `%` formatting in SQL (except parameterized drivers)
|
|
171
|
+
- Dynamic ORDER BY without validation
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Validation Considerations
|
|
176
|
+
|
|
177
|
+
- Code review: grep for `f"SELECT` `f"INSERT` `f"UPDATE` `f"DELETE`
|
|
178
|
+
- `bandit` SQL injection checks
|
|
179
|
+
- SQLMap testing
|
|
180
|
+
- ORM raw query audit
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Related Skills
|
|
185
|
+
|
|
186
|
+
- `security/input_validation.md`
|
|
187
|
+
- `engineering/database.md`
|
|
188
|
+
- `generation/error_handling.md`
|