create-dwy 0.1.0 → 0.2.0
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.
- package/package.json +1 -1
- package/templates/claude-global/hooks/pre-git-commit-sensitive-check.sh +59 -0
- package/templates/claude-global/rules/backend-security.md +592 -0
- package/templates/claude-global/rules/server-security.md +209 -75
- package/templates/claude-global/rules/vue-code-style.md +706 -0
- package/templates/claude-global/settings.json +15 -0
- package/templates/claude-global/hooks/.gitkeep +0 -0
package/package.json
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Claude Code PreToolUse Hook
|
|
3
|
+
# 在 git commit 前检查暂存文件中是否包含敏感数据
|
|
4
|
+
|
|
5
|
+
# 只拦截 git commit 命令
|
|
6
|
+
echo "$TOOL_INPUT" | grep -q 'git commit' || exit 0
|
|
7
|
+
|
|
8
|
+
# 获取暂存文件列表
|
|
9
|
+
STAGED=$(git diff --cached --diff-filter=ACMR --name-only 2>/dev/null)
|
|
10
|
+
[ -z "$STAGED" ] && exit 0
|
|
11
|
+
|
|
12
|
+
# 敏感数据匹配模式
|
|
13
|
+
PATTERNS=(
|
|
14
|
+
# 密码/密钥
|
|
15
|
+
'password'
|
|
16
|
+
'passwd'
|
|
17
|
+
'secret'
|
|
18
|
+
'api[_-]?key'
|
|
19
|
+
'access[_-]?token'
|
|
20
|
+
'private[_-]?key'
|
|
21
|
+
'auth[_-]?token'
|
|
22
|
+
'client[_-]?secret'
|
|
23
|
+
'bearer'
|
|
24
|
+
|
|
25
|
+
# 数据库连接串
|
|
26
|
+
'jdbc:'
|
|
27
|
+
'mysql://'
|
|
28
|
+
'postgres://'
|
|
29
|
+
'mongodb://'
|
|
30
|
+
'redis://'
|
|
31
|
+
|
|
32
|
+
# 私钥文件
|
|
33
|
+
'-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY'
|
|
34
|
+
|
|
35
|
+
# 平台 Token
|
|
36
|
+
'sk-[a-zA-Z0-9]{20,}' # OpenAI
|
|
37
|
+
'ghp_[a-zA-Z0-9]{36}' # GitHub
|
|
38
|
+
'AKIA[0-9A-Z]{16}' # AWS
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# 拼接为正则
|
|
42
|
+
REGEX=$(IFS='|'; echo "${PATTERNS[*]}")
|
|
43
|
+
|
|
44
|
+
# 扫描暂存内容
|
|
45
|
+
RESULT=$(git diff --cached -U0 2>/dev/null | grep -inE "$REGEX" 2>/dev/null)
|
|
46
|
+
|
|
47
|
+
if [ -n "$RESULT" ]; then
|
|
48
|
+
echo "========================================"
|
|
49
|
+
echo " WARNING: Sensitive data detected"
|
|
50
|
+
echo "========================================"
|
|
51
|
+
echo ""
|
|
52
|
+
echo "$RESULT"
|
|
53
|
+
echo ""
|
|
54
|
+
echo "Please review before committing."
|
|
55
|
+
echo "========================================"
|
|
56
|
+
exit 2 # 暂停等待人工审批
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
exit 0
|
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
# 后端安全编码规范
|
|
2
|
+
|
|
3
|
+
FastAPI + PostgreSQL + Redis + MinIO + DolphinDB 项目的安全编码规则。AI 生成或修改代码时,必须严格按照这些规则执行。
|
|
4
|
+
|
|
5
|
+
## 一、ID 设计(防遍历攻击)
|
|
6
|
+
|
|
7
|
+
### 核心原则
|
|
8
|
+
**数据库内部用自增 ID(性能),对外暴露 UUID(安全)。** 攻击者无法通过递增 ID 遍历所有数据。
|
|
9
|
+
|
|
10
|
+
### 强制规则
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
# ✅ 正确 — ORM 模型同时有 id 和 uuid
|
|
14
|
+
import uuid as uuid_lib
|
|
15
|
+
from sqlalchemy import String
|
|
16
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
17
|
+
|
|
18
|
+
class User(Base, TimestampMixin):
|
|
19
|
+
__tablename__ = "users"
|
|
20
|
+
|
|
21
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
22
|
+
uuid: Mapped[str] = mapped_column(String(36), default=lambda: str(uuid_lib.uuid4()), unique=True, index=True)
|
|
23
|
+
name: Mapped[str] = mapped_column(String(50))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ✅ 正确 — 路由用 UUID 查询
|
|
27
|
+
@router.get("/users/{user_uuid}", response_model=UserResponse)
|
|
28
|
+
async def get_user(user_uuid: str, service: UserService = Depends(get_user_service)):
|
|
29
|
+
return await service.get_by_uuid(user_uuid)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ✅ 正确 — Response Schema 只返回 uuid,不返回 id
|
|
33
|
+
class UserResponse(BaseModel):
|
|
34
|
+
uuid: str
|
|
35
|
+
name: str
|
|
36
|
+
# 不暴露 id 字段
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ❌ 违规 — 路由直接用自增 ID
|
|
40
|
+
@router.get("/users/{user_id}")
|
|
41
|
+
async def get_user(user_id: int): ...
|
|
42
|
+
|
|
43
|
+
# ❌ 违规 — Response 暴露自增 ID
|
|
44
|
+
class UserResponse(BaseModel):
|
|
45
|
+
id: int # 攻击者可遍历 1,2,3...
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 违规检测
|
|
49
|
+
如果在路由路径中发现 `/{name}_id` 且类型为 `int`(如 `user_id: int`),必须提示用户改为 UUID。
|
|
50
|
+
|
|
51
|
+
## 二、密码安全
|
|
52
|
+
|
|
53
|
+
### 强制规则
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
# ✅ 正确 — bcrypt 存储
|
|
57
|
+
from danweiyuan_base.security import hash_password, verify_password
|
|
58
|
+
|
|
59
|
+
hashed = hash_password("user_password") # 存储到数据库
|
|
60
|
+
is_valid = verify_password("input", hashed) # 验证时比对
|
|
61
|
+
|
|
62
|
+
# ❌ 违规 — 明文存储
|
|
63
|
+
user.password = "123456"
|
|
64
|
+
|
|
65
|
+
# ❌ 违规 — MD5/SHA 存储(可被彩虹表破解)
|
|
66
|
+
user.password = hashlib.md5(password.encode()).hexdigest()
|
|
67
|
+
|
|
68
|
+
# ❌ 违规 — 接口返回密码字段
|
|
69
|
+
class UserResponse(BaseModel):
|
|
70
|
+
id: int
|
|
71
|
+
name: str
|
|
72
|
+
password: str # 绝对禁止
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| 规则 | 说明 |
|
|
76
|
+
|------|------|
|
|
77
|
+
| 存储算法 | **只允许 bcrypt**(或 argon2),禁止 MD5/SHA/明文 |
|
|
78
|
+
| 接口返回 | **永远不返回**密码字段,Response Schema 中不包含 |
|
|
79
|
+
| 密码强度 | 用户密码最少 8 位,管理员密码最少 12 位 |
|
|
80
|
+
| 登录错误 | 返回"用户名或密码错误",**不区分**是用户名不存在还是密码错误 |
|
|
81
|
+
|
|
82
|
+
## 三、接口认证与授权
|
|
83
|
+
|
|
84
|
+
### 强制规则
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
# ✅ 所有 API 必须 JWT 认证(白名单除外)
|
|
88
|
+
WHITELIST = ["/api/health", "/api/auth/login", "/api/auth/register"]
|
|
89
|
+
|
|
90
|
+
# ✅ 路由级别认证
|
|
91
|
+
@router.get("/users", dependencies=[Depends(get_current_user)])
|
|
92
|
+
async def list_users(): ...
|
|
93
|
+
|
|
94
|
+
# ✅ 角色检查
|
|
95
|
+
@router.delete("/users/{uuid}", dependencies=[Depends(require_admin)])
|
|
96
|
+
async def delete_user(uuid: str): ...
|
|
97
|
+
|
|
98
|
+
# ❌ 违规 — 无认证的数据接口
|
|
99
|
+
@router.get("/users")
|
|
100
|
+
async def list_users(): ...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Token 安全
|
|
104
|
+
|
|
105
|
+
| 规则 | 说明 |
|
|
106
|
+
|------|------|
|
|
107
|
+
| Access Token 有效期 | **30 分钟**(最长不超过 2 小时) |
|
|
108
|
+
| Refresh Token 有效期 | **7 天** |
|
|
109
|
+
| Token 黑名单 | 登出后 token 必须失效(Redis 存黑名单,TTL = 剩余有效期) |
|
|
110
|
+
| Token 存储 | 前端存 `localStorage`(SPA),**禁止**存 Cookie 不设 HttpOnly |
|
|
111
|
+
| Secret Key | 从环境变量读取,最少 32 位随机字符串 |
|
|
112
|
+
|
|
113
|
+
## 四、输入校验
|
|
114
|
+
|
|
115
|
+
### 强制规则
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# ✅ Pydantic Schema 严格校验
|
|
119
|
+
class UserCreate(BaseModel):
|
|
120
|
+
name: str = Field(min_length=2, max_length=50)
|
|
121
|
+
email: EmailStr
|
|
122
|
+
password: str = Field(min_length=8, max_length=128)
|
|
123
|
+
age: int = Field(ge=0, le=150)
|
|
124
|
+
|
|
125
|
+
# ✅ 列表查询参数限制
|
|
126
|
+
class ListParams(BaseModel):
|
|
127
|
+
page: int = Field(default=1, ge=1)
|
|
128
|
+
page_size: int = Field(default=20, ge=1, le=100) # 上限 100
|
|
129
|
+
|
|
130
|
+
# ❌ 违规 — 不校验直接使用
|
|
131
|
+
@router.post("/users")
|
|
132
|
+
async def create_user(data: dict): ...
|
|
133
|
+
|
|
134
|
+
# ❌ 违规 — page_size 无上限
|
|
135
|
+
class ListParams(BaseModel):
|
|
136
|
+
page_size: int = 20 # 攻击者可传 999999
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 校验规则清单
|
|
140
|
+
|
|
141
|
+
| 字段类型 | 校验 |
|
|
142
|
+
|---------|------|
|
|
143
|
+
| 字符串 | `min_length` + `max_length` |
|
|
144
|
+
| 数字 | `ge` + `le`(最小值 + 最大值) |
|
|
145
|
+
| 邮箱 | `EmailStr` |
|
|
146
|
+
| 手机号 | `pattern=r"^1[3-9]\d{9}$"` |
|
|
147
|
+
| URL | `HttpUrl` |
|
|
148
|
+
| 列表 | `max_length`(限制数组长度) |
|
|
149
|
+
| 文件大小 | 后端二次校验(不信任前端) |
|
|
150
|
+
|
|
151
|
+
## 五、响应安全
|
|
152
|
+
|
|
153
|
+
### 强制规则
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
# ✅ Response Schema 严格控制返回字段
|
|
157
|
+
class UserResponse(BaseModel):
|
|
158
|
+
uuid: str
|
|
159
|
+
name: str
|
|
160
|
+
email: str
|
|
161
|
+
is_active: bool
|
|
162
|
+
created_at: datetime
|
|
163
|
+
|
|
164
|
+
model_config = ConfigDict(from_attributes=True)
|
|
165
|
+
|
|
166
|
+
# ❌ 违规 — 直接返回 ORM 对象
|
|
167
|
+
@router.get("/users/{uuid}")
|
|
168
|
+
async def get_user(uuid: str, db=Depends(get_db)):
|
|
169
|
+
user = await db.get(User, uuid)
|
|
170
|
+
return user # 可能暴露 password、内部 ID 等
|
|
171
|
+
|
|
172
|
+
# ✅ 列表接口必须分页
|
|
173
|
+
@router.get("/users", response_model=PaginatedResponse[UserResponse])
|
|
174
|
+
async def list_users(params: PaginationParams = Depends()): ...
|
|
175
|
+
|
|
176
|
+
# ❌ 违规 — 无分页返回全量数据
|
|
177
|
+
@router.get("/users")
|
|
178
|
+
async def list_users():
|
|
179
|
+
return await db.execute(select(User)) # 全量泄露
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### PII 脱敏
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
# ✅ 工具函数
|
|
186
|
+
def mask_phone(phone: str) -> str:
|
|
187
|
+
"""138****5678"""
|
|
188
|
+
if len(phone) != 11:
|
|
189
|
+
return phone
|
|
190
|
+
return phone[:3] + "****" + phone[7:]
|
|
191
|
+
|
|
192
|
+
def mask_id_card(id_card: str) -> str:
|
|
193
|
+
"""420***********1234"""
|
|
194
|
+
if len(id_card) != 18:
|
|
195
|
+
return id_card
|
|
196
|
+
return id_card[:3] + "***********" + id_card[14:]
|
|
197
|
+
|
|
198
|
+
# ✅ 在 Response Schema 中使用 validator 脱敏
|
|
199
|
+
class UserResponse(BaseModel):
|
|
200
|
+
phone: str
|
|
201
|
+
|
|
202
|
+
@field_validator("phone", mode="before")
|
|
203
|
+
@classmethod
|
|
204
|
+
def mask_phone_field(cls, v: str) -> str:
|
|
205
|
+
return mask_phone(v) if v else v
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## 六、错误处理安全
|
|
209
|
+
|
|
210
|
+
### 强制规则
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
# ✅ 生产环境只返回通用错误
|
|
214
|
+
raise NotFoundError("用户") # → {"code": "NOT_FOUND", "message": "用户不存在"}
|
|
215
|
+
raise BusinessError("操作失败") # → {"code": "UNKNOWN_ERROR", "message": "操作失败"}
|
|
216
|
+
|
|
217
|
+
# ❌ 违规 — 回显用户输入
|
|
218
|
+
raise BusinessError(f"未找到: {user_input}") # XSS/信息泄露
|
|
219
|
+
|
|
220
|
+
# ❌ 违规 — 暴露内部错误
|
|
221
|
+
except Exception as e:
|
|
222
|
+
return {"error": str(e)} # 可能包含 SQL、文件路径、堆栈
|
|
223
|
+
|
|
224
|
+
# ❌ 违规 — 暴露数据库结构
|
|
225
|
+
except IntegrityError as e:
|
|
226
|
+
return {"error": f"数据库错误: {e}"} # 暴露表名、字段名
|
|
227
|
+
|
|
228
|
+
# ✅ 正确 — 统一错误处理
|
|
229
|
+
except IntegrityError:
|
|
230
|
+
raise BusinessError("数据冲突,请检查是否重复提交", code="CONFLICT")
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### 生产环境禁止
|
|
234
|
+
|
|
235
|
+
| 禁止返回的内容 | 原因 |
|
|
236
|
+
|--------------|------|
|
|
237
|
+
| 完整异常堆栈 | 暴露代码结构和文件路径 |
|
|
238
|
+
| SQL 语句 | 暴露表结构和查询逻辑 |
|
|
239
|
+
| 文件系统路径 | 暴露服务器目录结构 |
|
|
240
|
+
| 用户输入回显 | XSS 和信息泄露 |
|
|
241
|
+
| 数据库字段名 | 暴露数据模型 |
|
|
242
|
+
| 第三方 API Key | 凭证泄露 |
|
|
243
|
+
|
|
244
|
+
## 七、注入防护
|
|
245
|
+
|
|
246
|
+
### SQL 注入
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
# ✅ ORM 参数化查询(安全)
|
|
250
|
+
result = await session.execute(select(User).where(User.id == user_id))
|
|
251
|
+
|
|
252
|
+
# ✅ 原生 SQL 参数化(安全)
|
|
253
|
+
result = await session.execute(text("SELECT * FROM users WHERE id = :id"), {"id": user_id})
|
|
254
|
+
|
|
255
|
+
# ❌ 违规 — 字符串拼接(SQL 注入)
|
|
256
|
+
result = await session.execute(text(f"SELECT * FROM users WHERE id = {user_id}"))
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### 命令注入
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
# ❌ 违规 — shell 命令注入
|
|
263
|
+
import os
|
|
264
|
+
os.system(f"convert {user_filename} output.png")
|
|
265
|
+
|
|
266
|
+
import subprocess
|
|
267
|
+
subprocess.run(f"ls {user_path}", shell=True)
|
|
268
|
+
|
|
269
|
+
# ✅ 正确 — 参数列表(不经过 shell)
|
|
270
|
+
subprocess.run(["convert", safe_filename, "output.png"], shell=False)
|
|
271
|
+
|
|
272
|
+
# ✅ 最佳 — 不调用外部命令,用 Python 库
|
|
273
|
+
from PIL import Image
|
|
274
|
+
img = Image.open(safe_path)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### 路径遍历
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
# ❌ 违规 — 用户控制文件路径
|
|
281
|
+
file_path = f"/uploads/{user_input_filename}"
|
|
282
|
+
|
|
283
|
+
# ✅ 正确 — 安全提取文件名
|
|
284
|
+
from pathlib import Path
|
|
285
|
+
safe_name = Path(user_input_filename).name # 去除 ../../../ 等
|
|
286
|
+
file_path = Path("/uploads") / safe_name
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### SSRF(服务端请求伪造)
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
# ❌ 违规 — 用户控制 URL 发起请求
|
|
293
|
+
url = request_body.url # 用户传入
|
|
294
|
+
response = await httpx.get(url) # 可能访问内网 169.254.169.254
|
|
295
|
+
|
|
296
|
+
# ✅ 正确 — 白名单限制
|
|
297
|
+
ALLOWED_HOSTS = ["api.example.com", "cdn.example.com"]
|
|
298
|
+
parsed = urlparse(url)
|
|
299
|
+
if parsed.hostname not in ALLOWED_HOSTS:
|
|
300
|
+
raise BusinessError("不允许的目标地址")
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## 八、软删除
|
|
304
|
+
|
|
305
|
+
### 强制规则
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
# ✅ 模型添加软删除字段
|
|
309
|
+
class User(Base, TimestampMixin):
|
|
310
|
+
__tablename__ = "users"
|
|
311
|
+
|
|
312
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
313
|
+
is_deleted: Mapped[bool] = mapped_column(default=False)
|
|
314
|
+
deleted_at: Mapped[datetime | None] = mapped_column(default=None)
|
|
315
|
+
|
|
316
|
+
# ✅ 删除操作只更新标记
|
|
317
|
+
async def delete_user(self, user_uuid: str) -> None:
|
|
318
|
+
user = await self.get_by_uuid(user_uuid)
|
|
319
|
+
user.is_deleted = True
|
|
320
|
+
user.deleted_at = datetime.now(timezone.utc)
|
|
321
|
+
await self.db.commit()
|
|
322
|
+
|
|
323
|
+
# ✅ 查询时自动排除已删除
|
|
324
|
+
async def list_users(self) -> list[User]:
|
|
325
|
+
result = await self.db.execute(
|
|
326
|
+
select(User).where(User.is_deleted == False)
|
|
327
|
+
)
|
|
328
|
+
return list(result.scalars().all())
|
|
329
|
+
|
|
330
|
+
# ❌ 违规 — 物理删除
|
|
331
|
+
async def delete_user(self, user_id: int) -> None:
|
|
332
|
+
user = await self.db.get(User, user_id)
|
|
333
|
+
await self.db.delete(user) # 数据永久丢失
|
|
334
|
+
await self.db.commit()
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### 例外
|
|
338
|
+
以下场景允许物理删除(但需用户确认):
|
|
339
|
+
- 合规要求的数据清除(如 GDPR 被遗忘权)
|
|
340
|
+
- 临时数据清理(验证码、过期 session)
|
|
341
|
+
|
|
342
|
+
## 九、审计日志
|
|
343
|
+
|
|
344
|
+
### 强制规则
|
|
345
|
+
|
|
346
|
+
需要记录审计日志的操作:
|
|
347
|
+
|
|
348
|
+
| 操作 | 记录内容 |
|
|
349
|
+
|------|---------|
|
|
350
|
+
| 登录成功/失败 | 用户名、IP、时间、User-Agent |
|
|
351
|
+
| 权限变更 | 操作人、目标用户、旧角色、新角色 |
|
|
352
|
+
| 数据删除 | 操作人、删除的资源类型和 UUID |
|
|
353
|
+
| 敏感数据查询 | 查询人、查询条件(脱敏) |
|
|
354
|
+
| 密码修改 | 操作人、时间 |
|
|
355
|
+
| 导出操作 | 操作人、导出数据范围 |
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
# ✅ 审计日志示例
|
|
359
|
+
import logging
|
|
360
|
+
|
|
361
|
+
audit_logger = logging.getLogger("audit")
|
|
362
|
+
|
|
363
|
+
async def delete_user(self, user_uuid: str, operator: dict) -> None:
|
|
364
|
+
user = await self.get_by_uuid(user_uuid)
|
|
365
|
+
user.is_deleted = True
|
|
366
|
+
await self.db.commit()
|
|
367
|
+
|
|
368
|
+
audit_logger.info(
|
|
369
|
+
"user_deleted operator=%s target=%s",
|
|
370
|
+
operator["uuid"],
|
|
371
|
+
user_uuid,
|
|
372
|
+
)
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
## 十、PostgreSQL 安全
|
|
376
|
+
|
|
377
|
+
### 强制规则
|
|
378
|
+
|
|
379
|
+
| 规则 | 说明 |
|
|
380
|
+
|------|------|
|
|
381
|
+
| 参数化查询 | **禁止**字符串拼接 SQL,必须 ORM 或参数化 |
|
|
382
|
+
| 应用用户权限 | 应用数据库用户**只给** SELECT/INSERT/UPDATE/DELETE,**不给** DROP/CREATE/ALTER |
|
|
383
|
+
| 连接池限制 | `pool_size` 不超过 20,`max_overflow` 不超过 10 |
|
|
384
|
+
| SSL 连接 | 生产环境建议启用 SSL(`sslmode=require`) |
|
|
385
|
+
| 敏感字段加密 | 身份证号等字段使用 AES 加密存储,不只是展示时脱敏 |
|
|
386
|
+
|
|
387
|
+
### 应用用户权限配置
|
|
388
|
+
```sql
|
|
389
|
+
-- 创建应用专用用户(非超级用户)
|
|
390
|
+
CREATE USER app_user WITH PASSWORD 'strong_password';
|
|
391
|
+
GRANT CONNECT ON DATABASE mydb TO app_user;
|
|
392
|
+
GRANT USAGE ON SCHEMA public TO app_user;
|
|
393
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
|
|
394
|
+
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
|
|
395
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
|
|
396
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO app_user;
|
|
397
|
+
|
|
398
|
+
-- ❌ 禁止给应用用户的权限
|
|
399
|
+
-- GRANT ALL PRIVILEGES
|
|
400
|
+
-- SUPERUSER
|
|
401
|
+
-- CREATEDB
|
|
402
|
+
-- CREATEROLE
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## 十一、Redis 安全
|
|
406
|
+
|
|
407
|
+
### 强制规则
|
|
408
|
+
|
|
409
|
+
| 规则 | 说明 |
|
|
410
|
+
|------|------|
|
|
411
|
+
| 认证 | **必须设置密码**(`requirepass`),不允许无密码 Redis |
|
|
412
|
+
| 绑定地址 | `bind 127.0.0.1`,**禁止** `bind 0.0.0.0` |
|
|
413
|
+
| 危险命令 | **禁用** `FLUSHALL`/`FLUSHDB`/`CONFIG`/`KEYS`/`DEBUG` |
|
|
414
|
+
| Key TTL | 所有 key **必须设置过期时间**,禁止无 TTL 的 key |
|
|
415
|
+
| Key 前缀 | 所有 key 必须有项目前缀(如 `myapp:token:xxx`) |
|
|
416
|
+
| 存储内容 | **禁止**在 Redis 存储敏感原文(密码、身份证明文) |
|
|
417
|
+
|
|
418
|
+
### Redis 配置
|
|
419
|
+
```
|
|
420
|
+
# redis.conf
|
|
421
|
+
requirepass your_strong_password
|
|
422
|
+
bind 127.0.0.1
|
|
423
|
+
rename-command FLUSHALL ""
|
|
424
|
+
rename-command FLUSHDB ""
|
|
425
|
+
rename-command CONFIG ""
|
|
426
|
+
rename-command KEYS ""
|
|
427
|
+
rename-command DEBUG ""
|
|
428
|
+
maxmemory 256mb
|
|
429
|
+
maxmemory-policy allkeys-lru
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Key 设计规范
|
|
433
|
+
```python
|
|
434
|
+
# ✅ 正确 — 有前缀、有 TTL
|
|
435
|
+
await redis.set("myapp:token:blacklist:abc123", "1", ex=3600)
|
|
436
|
+
await redis.set("myapp:cache:user:uuid123", json.dumps(user_data), ex=300)
|
|
437
|
+
|
|
438
|
+
# ❌ 违规 — 无前缀
|
|
439
|
+
await redis.set("abc123", "1")
|
|
440
|
+
|
|
441
|
+
# ❌ 违规 — 无 TTL
|
|
442
|
+
await redis.set("myapp:data:xxx", value) # 永不过期,内存泄漏
|
|
443
|
+
|
|
444
|
+
# ❌ 违规 — 存储敏感原文
|
|
445
|
+
await redis.set("myapp:user:password", "123456")
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
## 十二、MinIO / 文件存储安全
|
|
449
|
+
|
|
450
|
+
### 强制规则
|
|
451
|
+
|
|
452
|
+
| 规则 | 说明 |
|
|
453
|
+
|------|------|
|
|
454
|
+
| Bucket 访问 | **默认私有**,不开放 public 读 |
|
|
455
|
+
| 文件访问 | 通过**预签名 URL** 限时访问(默认 15 分钟) |
|
|
456
|
+
| 文件名 | **UUID 重命名**,禁止保留用户原始文件名做存储路径 |
|
|
457
|
+
| 文件类型 | 校验文件 **magic bytes**(不只检查扩展名) |
|
|
458
|
+
| 文件大小 | 后端二次校验大小限制(不信任前端或 Nginx) |
|
|
459
|
+
|
|
460
|
+
```python
|
|
461
|
+
# ✅ 正确 — UUID 重命名 + 预签名 URL
|
|
462
|
+
import uuid as uuid_lib
|
|
463
|
+
from minio import Minio
|
|
464
|
+
|
|
465
|
+
async def upload_file(file: UploadFile) -> str:
|
|
466
|
+
# 校验文件类型(magic bytes)
|
|
467
|
+
header = await file.read(8)
|
|
468
|
+
await file.seek(0)
|
|
469
|
+
if not is_allowed_file_type(header):
|
|
470
|
+
raise BusinessError("不允许的文件类型")
|
|
471
|
+
|
|
472
|
+
# UUID 重命名
|
|
473
|
+
ext = Path(file.filename).suffix
|
|
474
|
+
safe_name = f"{uuid_lib.uuid4()}{ext}"
|
|
475
|
+
|
|
476
|
+
client.put_object("uploads", safe_name, file.file, file.size)
|
|
477
|
+
return safe_name
|
|
478
|
+
|
|
479
|
+
def get_download_url(filename: str) -> str:
|
|
480
|
+
# 预签名 URL,15 分钟有效
|
|
481
|
+
return client.presigned_get_object("uploads", filename, expires=timedelta(minutes=15))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ❌ 违规 — 用户原始文件名
|
|
485
|
+
safe_name = file.filename # 可能包含 ../../../etc/passwd
|
|
486
|
+
|
|
487
|
+
# ❌ 违规 — 公开 bucket
|
|
488
|
+
client.set_bucket_policy("uploads", public_read_policy)
|
|
489
|
+
|
|
490
|
+
# ❌ 违规 — 只检查扩展名
|
|
491
|
+
if file.filename.endswith(".jpg"): # 攻击者可改扩展名
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
## 十三、DolphinDB 安全
|
|
495
|
+
|
|
496
|
+
### 强制规则
|
|
497
|
+
|
|
498
|
+
| 规则 | 说明 |
|
|
499
|
+
|------|------|
|
|
500
|
+
| 输入白名单 | 所有外部输入**必须经过正则白名单**验证后才能插入 DolphinDB 脚本 |
|
|
501
|
+
| 禁止拼接 | **禁止**将用户输入直接拼接到 DolphinDB 脚本字符串中 |
|
|
502
|
+
| 连接认证 | DolphinDB 连接**必须使用账号密码**,从环境变量读取 |
|
|
503
|
+
| 端口隔离 | DolphinDB 端口**禁止对外暴露**,仅 Docker 内部网络或 127.0.0.1 |
|
|
504
|
+
|
|
505
|
+
```python
|
|
506
|
+
# ✅ 正确 — 白名单验证后再插入脚本
|
|
507
|
+
import re
|
|
508
|
+
|
|
509
|
+
SAFE_COLUMN_NAME = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]{0,63}$')
|
|
510
|
+
SAFE_TABLE_NAME = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]{0,63}$')
|
|
511
|
+
|
|
512
|
+
def validate_column_name(name: str) -> str:
|
|
513
|
+
if not SAFE_COLUMN_NAME.match(name):
|
|
514
|
+
raise BusinessError(f"非法字段名: {name}")
|
|
515
|
+
return name
|
|
516
|
+
|
|
517
|
+
# 使用
|
|
518
|
+
col = validate_column_name(user_input_column)
|
|
519
|
+
script = f"select {col} from loadTable('dfs://mydb', 'mytable')"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# ❌ 违规 — 直接拼接用户输入
|
|
523
|
+
script = f"select * from loadTable('dfs://mydb', '{user_input}')" # DolphinDB 注入
|
|
524
|
+
|
|
525
|
+
# ❌ 违规 — 无验证
|
|
526
|
+
factor_name = request.query_params.get("factor")
|
|
527
|
+
script = f"select * from factors where factor_name = '{factor_name}'"
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### DolphinDB 连接安全
|
|
531
|
+
```python
|
|
532
|
+
# ✅ 正确 — 从环境变量读取
|
|
533
|
+
ddb_conn = ddb.Session()
|
|
534
|
+
ddb_conn.connect(
|
|
535
|
+
host=settings.dolphindb_host,
|
|
536
|
+
port=settings.dolphindb_port,
|
|
537
|
+
userid=settings.dolphindb_user,
|
|
538
|
+
password=settings.dolphindb_password,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
# ❌ 违规 — 硬编码
|
|
542
|
+
ddb_conn.connect("localhost", 8848, "admin", "123456")
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
## 十四、API 限流
|
|
546
|
+
|
|
547
|
+
### 强制规则
|
|
548
|
+
|
|
549
|
+
不同接口使用不同的限流策略:
|
|
550
|
+
|
|
551
|
+
| 接口类型 | 限流 | 原因 |
|
|
552
|
+
|---------|------|------|
|
|
553
|
+
| 登录 `/auth/login` | 3 次/分钟/IP | 防暴力破解 |
|
|
554
|
+
| 注册 `/auth/register` | 5 次/分钟/IP | 防批量注册 |
|
|
555
|
+
| 普通查询 | 60 次/分钟/IP | 防爬虫 |
|
|
556
|
+
| 数据导出 | 5 次/分钟/IP | 防数据泄露 |
|
|
557
|
+
| 文件上传 | 10 次/分钟/IP | 防资源滥用 |
|
|
558
|
+
|
|
559
|
+
```python
|
|
560
|
+
# ✅ 使用 slowapi 限流
|
|
561
|
+
from slowapi import Limiter
|
|
562
|
+
from slowapi.util import get_remote_address
|
|
563
|
+
|
|
564
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
565
|
+
|
|
566
|
+
@router.post("/auth/login")
|
|
567
|
+
@limiter.limit("3/minute")
|
|
568
|
+
async def login(request: Request, body: LoginRequest): ...
|
|
569
|
+
|
|
570
|
+
@router.get("/users/export")
|
|
571
|
+
@limiter.limit("5/minute")
|
|
572
|
+
async def export_users(request: Request): ...
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
## 十五、违规检测清单
|
|
576
|
+
|
|
577
|
+
AI 在编写或审查代码时,必须检查以下违规模式:
|
|
578
|
+
|
|
579
|
+
| 检查项 | 违规模式 | 严重程度 |
|
|
580
|
+
|--------|---------|---------|
|
|
581
|
+
| 自增 ID 暴露 | 路由参数为 `int` 类型的 ID | 高 |
|
|
582
|
+
| 密码明文 | `password` 字段出现在 Response Schema | 致命 |
|
|
583
|
+
| 密码弱存储 | 使用 MD5/SHA 而非 bcrypt | 致命 |
|
|
584
|
+
| 无认证接口 | 数据接口无 `Depends(get_current_user)` | 高 |
|
|
585
|
+
| SQL 拼接 | `f"SELECT ... {user_input}"` | 致命 |
|
|
586
|
+
| 命令注入 | `os.system()` / `subprocess(shell=True)` | 致命 |
|
|
587
|
+
| 全量返回 | 列表接口无分页限制 | 高 |
|
|
588
|
+
| 错误回显 | `str(exception)` 返回给客户端 | 高 |
|
|
589
|
+
| 物理删除 | `session.delete()` 用于业务数据 | 中 |
|
|
590
|
+
| Redis 无 TTL | `redis.set()` 没有 `ex` 参数 | 中 |
|
|
591
|
+
| 文件名不安全 | 使用用户原始文件名做存储路径 | 高 |
|
|
592
|
+
| DolphinDB 拼接 | 用户输入直接进 DolphinDB 脚本 | 致命 |
|