- Scrapy 爬蟲框架,爬取 HBR 繁體中文文章 - Flask Web 應用程式,提供文章查詢介面 - SQL Server 資料庫整合 - 自動化排程與郵件通知功能 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
870 lines
25 KiB
Markdown
870 lines
25 KiB
Markdown
# HBR Taiwan 爬蟲系統 - 安全審計報告
|
||
|
||
**審計日期**: 2024-12-22
|
||
**審計員**: Claude (AI Security Consultant)
|
||
**報告版本**: 1.0
|
||
**風險評級**: 🔴 **高風險** - 發現災難級漏洞,需立即修復
|
||
|
||
---
|
||
|
||
## 基本專案資訊
|
||
|
||
| 項目 | 內容 |
|
||
|------|------|
|
||
| **專案名稱** | HBR Taiwan Web Scraper |
|
||
| **專案描述** | 自動化爬取 Harvard Business Review Taiwan 文章的系統 |
|
||
| **目標用戶** | 內部/個人使用 |
|
||
| **處理的資料類型** | 文章元資料(標題、URL、作者、日期、分類、標籤)、文章內容 |
|
||
| **是否處理 PII** | 否 |
|
||
| **是否處理付款資訊** | 否 |
|
||
| **是否有 UGC** | 否 |
|
||
| **技術棧** | Python 3.11+, Scrapy, MySQL/MariaDB, Gmail SMTP |
|
||
| **部署環境** | GitHub Actions (私有倉庫), 本地 Crontab |
|
||
| **外部依賴** | scrapy>=2.11.0, itemadapter>=0.7.0, pymysql>=1.1.0, python-dateutil>=2.8.2 |
|
||
| **資料庫位置** | mysql.theaken.com:33306 (公網可訪問) |
|
||
| **未來計畫** | 將新增 Web 介面/API |
|
||
|
||
---
|
||
|
||
## 執行摘要
|
||
|
||
本次審計發現 **3 個高風險**、**4 個中風險**、**3 個低風險** 問題。最嚴重的問題是 **資料庫密碼硬編碼在多個原始碼檔案中**,且該資料庫可從公網訪問。這是一個典型的「新手災難級錯誤」,必須立即修復。
|
||
|
||
### 風險統計
|
||
|
||
| 風險等級 | 數量 | 狀態 |
|
||
|----------|------|------|
|
||
| 🔴 高風險 (High) | 3 | 需立即修復 |
|
||
| 🟠 中風險 (Medium) | 4 | 建議盡快修復 |
|
||
| 🟢 低風險 (Low) | 3 | 建議改善 |
|
||
|
||
---
|
||
|
||
## 第一部分:災難級新手錯誤檢查
|
||
|
||
### 🔴 HIGH-001: 資料庫密碼硬編碼在原始碼中
|
||
|
||
* **風險等級**: `High` - 災難級
|
||
* **威脅描述**: 資料庫連線密碼 `Aa123456` 直接硬編碼在多個 Python 檔案中,包括 `settings.py`、`database.py` 和 `test_db_connection.py`。由於資料庫伺服器可從公網訪問,任何能夠看到這些檔案的人都可以直接連線到您的資料庫。
|
||
* **受影響組件**:
|
||
- `hbr_crawler/hbr_crawler/settings.py` (第 64-68 行)
|
||
- `hbr_crawler/hbr_crawler/database.py` (第 220, 227 行)
|
||
- `test_db_connection.py` (第 26-32 行)
|
||
|
||
---
|
||
|
||
**駭客劇本 (Hacker's Playbook)**:
|
||
|
||
> 「我是一個好奇的人,偶然間在某個地方看到了這個專案的原始碼(可能是開發者不小心把私有倉庫設成公開、在論壇分享了程式碼片段、或者我拿到了開發者的電腦存取權限)。
|
||
>
|
||
> 我打開 `settings.py`,哇!第 67 行寫著 `DB_PASSWORD = 'Aa123456'`。再往上看,主機是 `mysql.theaken.com`,埠號是 `33306`,用戶名是 `A101`,資料庫名是 `HBR_scraper`。
|
||
>
|
||
> 我打開終端機,輸入:
|
||
> ```bash
|
||
> mysql -h mysql.theaken.com -P 33306 -u A101 -pAa123456
|
||
> ```
|
||
>
|
||
> 成功連線!現在我可以:
|
||
> 1. **讀取所有資料** - `SELECT * FROM articles;`
|
||
> 2. **刪除所有資料** - `DROP TABLE articles;`(如果用戶權限允許)
|
||
> 3. **植入惡意資料** - 在文章內容中注入惡意腳本
|
||
> 4. **竊取其他資料庫** - 如果這個用戶有權限訪問其他資料庫
|
||
> 5. **作為跳板** - 使用這個資料庫伺服器攻擊同一網路中的其他系統
|
||
>
|
||
> 更糟糕的是,密碼 `Aa123456` 是一個極弱的密碼,即使我沒看到原始碼,使用暴力破解工具幾分鐘內就能猜出來。」
|
||
|
||
---
|
||
|
||
**修復原理 (Principle of the Fix)**:
|
||
|
||
> 為什麼不能把密碼寫在程式碼裡?想像一下,你的程式碼是一本「食譜書」,你會把食譜印成書賣給很多人看。你會在食譜裡寫「我家保險箱的密碼是 123456」嗎?當然不會!
|
||
>
|
||
> 正確的做法是把密碼放在「環境變數」裡。環境變數就像是寫在便條紙上、只有你自己能看到的秘密。程式執行時會去讀這張便條紙,但便條紙不會被複製到食譜書裡。
|
||
>
|
||
> 在不同環境(開發、測試、生產),你可以用不同的便條紙(不同的環境變數),這樣也更安全、更靈活。
|
||
|
||
---
|
||
|
||
* **修復建議與程式碼範例**:
|
||
|
||
**步驟 1**: 建立 `.env` 檔案(此檔案絕對不能提交到版本控制)
|
||
|
||
```bash
|
||
# .env (放在專案根目錄)
|
||
DB_HOST=mysql.theaken.com
|
||
DB_PORT=33306
|
||
DB_USER=A101
|
||
DB_PASSWORD=您的新強密碼
|
||
DB_NAME=HBR_scraper
|
||
```
|
||
|
||
**步驟 2**: 建立 `.gitignore` 檔案(如果未來要使用 Git)
|
||
|
||
```gitignore
|
||
# .gitignore
|
||
.env
|
||
*.env
|
||
.env.*
|
||
__pycache__/
|
||
*.pyc
|
||
.venv/
|
||
venv/
|
||
hbr_articles.csv
|
||
*.log
|
||
```
|
||
|
||
**步驟 3**: 安裝 python-dotenv
|
||
|
||
```bash
|
||
pip install python-dotenv
|
||
```
|
||
|
||
並更新 `requirements.txt`:
|
||
```
|
||
scrapy>=2.11.0
|
||
itemadapter>=0.7.0
|
||
pymysql>=1.1.0
|
||
python-dateutil>=2.8.2
|
||
python-dotenv>=1.0.0
|
||
```
|
||
|
||
**步驟 4**: 修改 `settings.py`
|
||
|
||
```python
|
||
# 修改前 (危險!)
|
||
DB_HOST = 'mysql.theaken.com'
|
||
DB_PORT = 33306
|
||
DB_USER = 'A101'
|
||
DB_PASSWORD = 'Aa123456' # 🔴 密碼暴露!
|
||
DB_NAME = 'HBR_scraper'
|
||
|
||
# 修改後 (安全)
|
||
import os
|
||
from dotenv import load_dotenv
|
||
|
||
# 載入 .env 檔案
|
||
load_dotenv()
|
||
|
||
DB_HOST = os.environ.get('DB_HOST', 'localhost')
|
||
DB_PORT = int(os.environ.get('DB_PORT', 3306))
|
||
DB_USER = os.environ.get('DB_USER')
|
||
DB_PASSWORD = os.environ.get('DB_PASSWORD')
|
||
DB_NAME = os.environ.get('DB_NAME')
|
||
|
||
# 檢查必要的環境變數
|
||
if not all([DB_USER, DB_PASSWORD, DB_NAME]):
|
||
raise ValueError("Missing required database environment variables: DB_USER, DB_PASSWORD, DB_NAME")
|
||
```
|
||
|
||
**步驟 5**: 修改 `database.py` 的 `get_database_manager()` 函數
|
||
|
||
```python
|
||
# 修改前 (危險!)
|
||
def get_database_manager() -> DatabaseManager:
|
||
import os
|
||
try:
|
||
from scrapy.utils.project import get_project_settings
|
||
settings = get_project_settings()
|
||
host = settings.get('DB_HOST', os.environ.get('DB_HOST', 'mysql.theaken.com'))
|
||
# ... 預設值包含真實密碼 ...
|
||
password = settings.get('DB_PASSWORD', os.environ.get('DB_PASSWORD', 'Aa123456')) # 🔴
|
||
except:
|
||
password = os.environ.get('DB_PASSWORD', 'Aa123456') # 🔴
|
||
|
||
# 修改後 (安全)
|
||
def get_database_manager() -> DatabaseManager:
|
||
import os
|
||
from dotenv import load_dotenv
|
||
load_dotenv()
|
||
|
||
try:
|
||
from scrapy.utils.project import get_project_settings
|
||
settings = get_project_settings()
|
||
host = settings.get('DB_HOST') or os.environ.get('DB_HOST')
|
||
port = settings.getint('DB_PORT', int(os.environ.get('DB_PORT', 3306)))
|
||
user = settings.get('DB_USER') or os.environ.get('DB_USER')
|
||
password = settings.get('DB_PASSWORD') or os.environ.get('DB_PASSWORD')
|
||
database = settings.get('DB_NAME') or os.environ.get('DB_NAME')
|
||
except:
|
||
host = os.environ.get('DB_HOST')
|
||
port = int(os.environ.get('DB_PORT', 3306))
|
||
user = os.environ.get('DB_USER')
|
||
password = os.environ.get('DB_PASSWORD')
|
||
database = os.environ.get('DB_NAME')
|
||
|
||
if not all([host, user, password]):
|
||
raise ValueError("Database configuration missing. Please set environment variables.")
|
||
|
||
return DatabaseManager(host, port, user, password, database)
|
||
```
|
||
|
||
**步驟 6**: 修改 `test_db_connection.py`
|
||
|
||
```python
|
||
# 修改前 (危險!)
|
||
DB_CONFIG = {
|
||
'host': 'mysql.theaken.com',
|
||
'port': 33306,
|
||
'user': 'A101',
|
||
'password': 'Aa123456', # 🔴
|
||
'database': 'HBR_scraper'
|
||
}
|
||
|
||
# 修改後 (安全)
|
||
import os
|
||
from dotenv import load_dotenv
|
||
load_dotenv()
|
||
|
||
DB_CONFIG = {
|
||
'host': os.environ.get('DB_HOST'),
|
||
'port': int(os.environ.get('DB_PORT', 3306)),
|
||
'user': os.environ.get('DB_USER'),
|
||
'password': os.environ.get('DB_PASSWORD'),
|
||
'database': os.environ.get('DB_NAME')
|
||
}
|
||
|
||
# 驗證配置
|
||
if not all([DB_CONFIG['host'], DB_CONFIG['user'], DB_CONFIG['password']]):
|
||
print("錯誤: 請設定環境變數 DB_HOST, DB_USER, DB_PASSWORD")
|
||
sys.exit(1)
|
||
```
|
||
|
||
**步驟 7**: 立即更換資料庫密碼
|
||
|
||
由於密碼已經暴露在原始碼中,即使您現在修復了程式碼,原來的密碼可能已經被他人知道。請:
|
||
|
||
1. 登入 MySQL 伺服器
|
||
2. 更換 A101 用戶的密碼為強密碼(至少 16 字元,包含大小寫字母、數字、特殊符號)
|
||
3. 更新 `.env` 檔案使用新密碼
|
||
4. 更新 GitHub Actions secrets
|
||
|
||
```sql
|
||
-- 在 MySQL 中更換密碼
|
||
ALTER USER 'A101'@'%' IDENTIFIED BY '新的強密碼';
|
||
FLUSH PRIVILEGES;
|
||
```
|
||
|
||
---
|
||
|
||
### 🔴 HIGH-002: 弱密碼 - 資料庫密碼強度不足
|
||
|
||
* **風險等級**: `High`
|
||
* **威脅描述**: 目前使用的密碼 `Aa123456` 是一個極其常見的弱密碼,在大多數密碼字典攻擊中會在前 100 個嘗試內被破解。
|
||
* **受影響組件**: MySQL 用戶 `A101`
|
||
|
||
---
|
||
|
||
**駭客劇本 (Hacker's Playbook)**:
|
||
|
||
> 「即使我沒有看到原始碼,我知道 `mysql.theaken.com:33306` 是一個公開的 MySQL 伺服器。我啟動 Hydra(一個密碼暴力破解工具),使用常見密碼字典:
|
||
>
|
||
> ```bash
|
||
> hydra -l A101 -P /usr/share/wordlists/rockyou.txt mysql://mysql.theaken.com:33306
|
||
> ```
|
||
>
|
||
> 不到一分鐘,工具告訴我密碼是 `Aa123456`。這個密碼太常見了,幾乎每個密碼字典都有它。」
|
||
|
||
---
|
||
|
||
* **修復建議**:
|
||
|
||
1. 使用密碼管理器生成強密碼(建議 20+ 字元)
|
||
2. 密碼應包含:
|
||
- 大寫字母 (A-Z)
|
||
- 小寫字母 (a-z)
|
||
- 數字 (0-9)
|
||
- 特殊符號 (!@#$%^&*)
|
||
3. 避免使用:
|
||
- 字典單詞
|
||
- 常見模式(123456, password, qwerty)
|
||
- 個人資訊(生日、名字)
|
||
|
||
**強密碼範例** (請勿直接使用,僅作示例):
|
||
```
|
||
Hbr$Cr@wl3r#2024!Secure_DB
|
||
```
|
||
|
||
---
|
||
|
||
### 🔴 HIGH-003: 資料庫伺服器公網暴露且缺乏防護
|
||
|
||
* **風險等級**: `High`
|
||
* **威脅描述**: MySQL 伺服器 `mysql.theaken.com:33306` 可從公網直接訪問,沒有 IP 白名單限制,增加了被暴力破解和未授權訪問的風險。
|
||
* **受影響組件**: MySQL 伺服器配置
|
||
|
||
---
|
||
|
||
**駭客劇本 (Hacker's Playbook)**:
|
||
|
||
> 「我使用 Shodan(物聯網搜尋引擎)搜尋開放的 MySQL 伺服器:
|
||
>
|
||
> ```
|
||
> port:33306 product:MySQL
|
||
> ```
|
||
>
|
||
> 發現 `mysql.theaken.com` 出現在列表中。這個伺服器對全世界開放,任何人都可以嘗試連線。我可以:
|
||
> 1. 使用暴力破解工具嘗試常見用戶名/密碼組合
|
||
> 2. 嘗試已知的 MySQL 漏洞
|
||
> 3. 進行 DoS 攻擊使服務不可用」
|
||
|
||
---
|
||
|
||
* **修復建議**:
|
||
|
||
**方案 A**: 設定防火牆白名單(推薦)
|
||
|
||
```bash
|
||
# 在伺服器上設定 iptables,只允許特定 IP 訪問
|
||
# 假設您的 IP 是 203.0.113.100,GitHub Actions IP 範圍需另外查詢
|
||
|
||
# 先封鎖所有對 33306 埠的訪問
|
||
iptables -A INPUT -p tcp --dport 33306 -j DROP
|
||
|
||
# 允許特定 IP
|
||
iptables -I INPUT -p tcp -s 203.0.113.100 --dport 33306 -j ACCEPT
|
||
|
||
# 允許 GitHub Actions IP 範圍(需定期更新)
|
||
# 可從 https://api.github.com/meta 獲取
|
||
```
|
||
|
||
**方案 B**: 使用 SSH 隧道
|
||
|
||
```bash
|
||
# 不直接暴露 MySQL,而是通過 SSH 隧道連線
|
||
ssh -L 3306:localhost:3306 user@mysql.theaken.com
|
||
|
||
# 然後本地連線
|
||
mysql -h 127.0.0.1 -P 3306 -u A101 -p
|
||
```
|
||
|
||
**方案 C**: 使用 VPN
|
||
|
||
設定 VPN 伺服器,只有連接到 VPN 後才能訪問資料庫。
|
||
|
||
**方案 D**: MySQL 用戶 IP 限制
|
||
|
||
```sql
|
||
-- 限制 A101 用戶只能從特定 IP 連線
|
||
-- 先刪除現有用戶
|
||
DROP USER 'A101'@'%';
|
||
|
||
-- 創建只允許從特定 IP 連線的用戶
|
||
CREATE USER 'A101'@'203.0.113.100' IDENTIFIED BY '新的強密碼';
|
||
GRANT ALL PRIVILEGES ON HBR_scraper.* TO 'A101'@'203.0.113.100';
|
||
|
||
-- 如果需要 GitHub Actions,需要為其 IP 範圍創建用戶
|
||
-- 注意:GitHub Actions IP 範圍很廣,此方案可能不實際
|
||
```
|
||
|
||
---
|
||
|
||
## 第二部分:標準應用程式安全審計
|
||
|
||
### 🟠 MEDIUM-001: 缺少 .gitignore 檔案
|
||
|
||
* **風險等級**: `Medium`
|
||
* **威脅描述**: 專案沒有 `.gitignore` 檔案。如果未來將專案加入版本控制,可能會意外提交敏感檔案(如 `.env`、`hbr_articles.csv`、`__pycache__`)。
|
||
* **受影響組件**: 整個專案
|
||
|
||
* **修復建議**:
|
||
|
||
創建 `.gitignore` 檔案:
|
||
|
||
```gitignore
|
||
# 環境變數(最重要!)
|
||
.env
|
||
.env.*
|
||
*.env
|
||
|
||
# Python
|
||
__pycache__/
|
||
*.py[cod]
|
||
*$py.class
|
||
*.so
|
||
.Python
|
||
build/
|
||
develop-eggs/
|
||
dist/
|
||
downloads/
|
||
eggs/
|
||
.eggs/
|
||
lib/
|
||
lib64/
|
||
parts/
|
||
sdist/
|
||
var/
|
||
wheels/
|
||
*.egg-info/
|
||
.installed.cfg
|
||
*.egg
|
||
|
||
# 虛擬環境
|
||
.venv/
|
||
venv/
|
||
ENV/
|
||
|
||
# IDE
|
||
.idea/
|
||
.vscode/
|
||
*.swp
|
||
*.swo
|
||
|
||
# 專案特定
|
||
hbr_articles.csv
|
||
*.csv
|
||
*.log
|
||
logs/
|
||
|
||
# Scrapy
|
||
.scrapy/
|
||
|
||
# 資料庫備份
|
||
*.sql.bak
|
||
*.sql.backup
|
||
```
|
||
|
||
---
|
||
|
||
### 🟠 MEDIUM-002: SQL 注入風險 - create_database 函數
|
||
|
||
* **風險等級**: `Medium`
|
||
* **威脅描述**: `database.py` 中的 `create_database` 函數使用字串格式化構建 SQL 語句,而非參數化查詢,存在 SQL 注入風險。
|
||
* **受影響組件**: `hbr_crawler/hbr_crawler/database.py` (第 107 行)
|
||
|
||
```python
|
||
# 危險的程式碼
|
||
cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{database_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||
```
|
||
|
||
雖然使用了反引號包裹,但如果 `database_name` 包含特殊字元(如反引號本身),仍可能導致 SQL 注入。
|
||
|
||
* **修復建議**:
|
||
|
||
```python
|
||
# 修改後 - 驗證資料庫名稱
|
||
import re
|
||
|
||
def create_database(self, database_name: str) -> bool:
|
||
# 驗證資料庫名稱只包含安全字元
|
||
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', database_name):
|
||
logger.error(f"無效的資料庫名稱: {database_name}")
|
||
return False
|
||
|
||
try:
|
||
with self.get_connection(None) as conn:
|
||
with conn.cursor() as cursor:
|
||
# 由於已驗證名稱,現在可以安全使用
|
||
cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{database_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||
conn.commit()
|
||
logger.info(f"資料庫 {database_name} 建立成功(或已存在)")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"建立資料庫失敗: {e}")
|
||
return False
|
||
```
|
||
|
||
---
|
||
|
||
### 🟠 MEDIUM-003: 資料庫連線未使用 SSL/TLS 加密
|
||
|
||
* **風險等級**: `Medium`
|
||
* **威脅描述**: 雖然 SDD 文件提到應使用 SSL/TLS 加密連線,但實際程式碼中並未實作。資料在傳輸過程中以明文形式傳送,可能被中間人攻擊竊取。
|
||
* **受影響組件**: `hbr_crawler/hbr_crawler/database.py` (第 50-59 行)
|
||
|
||
* **修復建議**:
|
||
|
||
```python
|
||
# 修改前
|
||
connection = pymysql.connect(
|
||
host=self.host,
|
||
port=self.port,
|
||
user=self.user,
|
||
password=self.password,
|
||
database=db_name,
|
||
charset=self.charset,
|
||
cursorclass=pymysql.cursors.DictCursor,
|
||
autocommit=False
|
||
)
|
||
|
||
# 修改後 - 加入 SSL 設定
|
||
import ssl
|
||
|
||
def __init__(self, host: str, port: int, user: str, password: str,
|
||
database: str = None, charset: str = 'utf8mb4', use_ssl: bool = True):
|
||
# ... 原有程式碼 ...
|
||
self.use_ssl = use_ssl
|
||
|
||
@contextmanager
|
||
def get_connection(self, database: Optional[str] = None):
|
||
db_name = database or self.database
|
||
|
||
# SSL 設定
|
||
ssl_config = None
|
||
if self.use_ssl:
|
||
ssl_config = {
|
||
'ssl': {
|
||
'ssl_disabled': False,
|
||
'check_hostname': True,
|
||
'verify_mode': ssl.CERT_REQUIRED,
|
||
# 如果有 CA 證書,可以指定
|
||
# 'ca': '/path/to/ca-cert.pem'
|
||
}
|
||
}
|
||
|
||
try:
|
||
connect_args = {
|
||
'host': self.host,
|
||
'port': self.port,
|
||
'user': self.user,
|
||
'password': self.password,
|
||
'database': db_name,
|
||
'charset': self.charset,
|
||
'cursorclass': pymysql.cursors.DictCursor,
|
||
'autocommit': False
|
||
}
|
||
|
||
if ssl_config:
|
||
connect_args.update(ssl_config)
|
||
|
||
connection = pymysql.connect(**connect_args)
|
||
yield connection
|
||
connection.commit()
|
||
# ... 原有錯誤處理 ...
|
||
```
|
||
|
||
---
|
||
|
||
### 🟠 MEDIUM-004: 錯誤處理可能洩漏敏感資訊
|
||
|
||
* **風險等級**: `Medium`
|
||
* **威脅描述**: 多處錯誤處理直接記錄完整的例外訊息,這些訊息可能包含敏感資訊(如資料庫連線字串、檔案路徑等)。當未來新增 Web 介面時,這些錯誤訊息可能會暴露給最終用戶。
|
||
* **受影響組件**:
|
||
- `database.py` (多處 logger.error)
|
||
- `pipelines.py` (多處 logger.error)
|
||
|
||
* **修復建議**:
|
||
|
||
```python
|
||
# 修改前 - 可能洩漏敏感資訊
|
||
logger.error(f"資料庫連線錯誤: {e}")
|
||
|
||
# 修改後 - 安全的錯誤記錄
|
||
import traceback
|
||
|
||
# 開發環境可以記錄詳細錯誤
|
||
if os.environ.get('DEBUG', 'false').lower() == 'true':
|
||
logger.error(f"資料庫連線錯誤: {e}")
|
||
logger.debug(traceback.format_exc())
|
||
else:
|
||
# 生產環境只記錄錯誤類型,不記錄詳細訊息
|
||
logger.error(f"資料庫連線錯誤: {type(e).__name__}")
|
||
|
||
# 當未來有 Web 介面時,返回給用戶的錯誤訊息
|
||
def get_user_friendly_error(e: Exception) -> str:
|
||
"""返回對用戶友善的錯誤訊息,不洩漏內部細節"""
|
||
error_map = {
|
||
'OperationalError': '資料庫暫時無法連線,請稍後再試',
|
||
'IntegrityError': '資料處理錯誤,請聯繫管理員',
|
||
'ProgrammingError': '系統錯誤,請聯繫管理員',
|
||
}
|
||
return error_map.get(type(e).__name__, '發生未知錯誤,請稍後再試')
|
||
```
|
||
|
||
---
|
||
|
||
### 🟢 LOW-001: 未實作資料庫連線池
|
||
|
||
* **風險等級**: `Low`
|
||
* **威脅描述**: 每次資料庫操作都會建立新連線,效能較差且可能耗盡資料庫連線數。
|
||
* **受影響組件**: `hbr_crawler/hbr_crawler/database.py`
|
||
|
||
* **修復建議**:
|
||
|
||
```python
|
||
# 使用連線池
|
||
from dbutils.pooled_db import PooledDB
|
||
|
||
class DatabaseManager:
|
||
_pool = None
|
||
|
||
@classmethod
|
||
def get_pool(cls, host, port, user, password, database, charset='utf8mb4'):
|
||
if cls._pool is None:
|
||
cls._pool = PooledDB(
|
||
creator=pymysql,
|
||
maxconnections=10,
|
||
mincached=2,
|
||
maxcached=5,
|
||
blocking=True,
|
||
host=host,
|
||
port=port,
|
||
user=user,
|
||
password=password,
|
||
database=database,
|
||
charset=charset,
|
||
cursorclass=pymysql.cursors.DictCursor
|
||
)
|
||
return cls._pool
|
||
|
||
@contextmanager
|
||
def get_connection(self, database: Optional[str] = None):
|
||
conn = self.get_pool(...).connection()
|
||
try:
|
||
yield conn
|
||
conn.commit()
|
||
except:
|
||
conn.rollback()
|
||
raise
|
||
finally:
|
||
conn.close() # 歸還到連線池,非真正關閉
|
||
```
|
||
|
||
需要安裝 DBUtils:
|
||
```bash
|
||
pip install DBUtils
|
||
```
|
||
|
||
---
|
||
|
||
### 🟢 LOW-002: GitHub Actions 工作流程安全性可加強
|
||
|
||
* **風險等級**: `Low`
|
||
* **威脅描述**: GitHub Actions 工作流程沒有明確限制權限,使用預設權限可能過於寬鬆。
|
||
* **受影響組件**: `.github/workflows/weekly.yml`
|
||
|
||
* **修復建議**:
|
||
|
||
```yaml
|
||
name: weekly-crawl
|
||
on:
|
||
schedule:
|
||
- cron: "0 0 * * 1"
|
||
workflow_dispatch: {}
|
||
|
||
# 明確限制權限
|
||
permissions:
|
||
contents: read
|
||
|
||
jobs:
|
||
crawl-and-mail:
|
||
runs-on: ubuntu-latest
|
||
# 限制可訪問的 secrets
|
||
environment: production
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
- uses: actions/setup-python@v5
|
||
with:
|
||
python-version: "3.11"
|
||
|
||
# 使用 pip cache 加速
|
||
- name: Cache pip packages
|
||
uses: actions/cache@v3
|
||
with:
|
||
path: ~/.cache/pip
|
||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
|
||
|
||
- name: Install dependencies
|
||
run: pip install -r requirements.txt
|
||
|
||
- name: Run crawler
|
||
run: |
|
||
cd hbr_crawler
|
||
scrapy crawl hbr
|
||
|
||
- name: Send mail with CSV
|
||
env:
|
||
GMAIL_USERNAME: ${{ secrets.GMAIL_USERNAME }}
|
||
GMAIL_APP_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }}
|
||
MAIL_TO: ${{ secrets.MAIL_TO }}
|
||
# 加入資料庫環境變數
|
||
DB_HOST: ${{ secrets.DB_HOST }}
|
||
DB_PORT: ${{ secrets.DB_PORT }}
|
||
DB_USER: ${{ secrets.DB_USER }}
|
||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||
DB_NAME: ${{ secrets.DB_NAME }}
|
||
run: python send_mail.py hbr_articles.csv
|
||
|
||
- name: Upload CSV as artifact
|
||
uses: actions/upload-artifact@v4
|
||
with:
|
||
name: hbr_articles_csv
|
||
path: hbr_articles.csv
|
||
retention-days: 7 # 限制保留天數
|
||
```
|
||
|
||
---
|
||
|
||
### 🟢 LOW-003: 缺少輸入驗證
|
||
|
||
* **風險等級**: `Low`
|
||
* **威脅描述**: 爬蟲提取的資料沒有經過驗證就直接儲存,可能導致 XSS 攻擊(當未來有 Web 介面時)或資料完整性問題。
|
||
* **受影響組件**: `hbr_crawler/hbr_crawler/spiders/hbr.py`, `pipelines.py`
|
||
|
||
* **修復建議**:
|
||
|
||
```python
|
||
# 在 pipelines.py 中加入資料清理
|
||
import html
|
||
import re
|
||
|
||
def sanitize_html(text: str) -> str:
|
||
"""移除或轉義 HTML 標籤"""
|
||
if not text:
|
||
return ''
|
||
# 移除 script 標籤
|
||
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||
# 轉義 HTML 特殊字元
|
||
text = html.escape(text)
|
||
return text.strip()
|
||
|
||
def validate_url(url: str) -> bool:
|
||
"""驗證 URL 格式"""
|
||
pattern = r'^https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&\'()*+,;=%]+$'
|
||
return bool(re.match(pattern, url))
|
||
|
||
class DatabasePipeline:
|
||
def process_item(self, item, spider):
|
||
adapter = ItemAdapter(item)
|
||
|
||
# 驗證 URL
|
||
url = adapter.get('url', '')
|
||
if not validate_url(url):
|
||
logger.warning(f"無效的 URL: {url}")
|
||
return item
|
||
|
||
# 清理文字欄位
|
||
for field in ['title', 'summary', 'content', 'author']:
|
||
if adapter.get(field):
|
||
adapter[field] = sanitize_html(adapter[field])
|
||
|
||
# ... 原有邏輯 ...
|
||
```
|
||
|
||
---
|
||
|
||
## 第三部分:OWASP Top 10 (2021) 檢查清單
|
||
|
||
| 編號 | 漏洞類型 | 狀態 | 說明 |
|
||
|------|---------|------|------|
|
||
| A01 | Broken Access Control | ⚠️ 需注意 | 未來 Web 介面需實作適當的存取控制 |
|
||
| A02 | Cryptographic Failures | 🔴 問題 | 密碼硬編碼、弱密碼、未使用 SSL |
|
||
| A03 | Injection | ⚠️ 部分問題 | `create_database` 有潛在 SQL 注入風險,其他查詢已使用參數化 |
|
||
| A04 | Insecure Design | ✅ 尚可 | 基本設計合理 |
|
||
| A05 | Security Misconfiguration | 🔴 問題 | 資料庫公網暴露、缺少 .gitignore |
|
||
| A06 | Vulnerable Components | ⚠️ 需檢查 | 需定期更新依賴套件 |
|
||
| A07 | Auth Failures | ⚠️ 需注意 | 未來 Web 介面需實作適當的身分驗證 |
|
||
| A08 | Software/Data Integrity | ✅ 良好 | 使用 GitHub Actions 官方 actions |
|
||
| A09 | Logging Failures | ⚠️ 需改善 | 日誌可能洩漏敏感資訊 |
|
||
| A10 | SSRF | ✅ 不適用 | 爬蟲目標是固定的,無 SSRF 風險 |
|
||
|
||
---
|
||
|
||
## 第四部分:依賴套件安全性分析
|
||
|
||
### 目前依賴 (requirements.txt)
|
||
|
||
| 套件 | 版本要求 | 已知漏洞 | 建議 |
|
||
|------|---------|---------|------|
|
||
| scrapy | >=2.11.0 | 無重大漏洞 | 保持更新 |
|
||
| itemadapter | >=0.7.0 | 無已知漏洞 | 保持更新 |
|
||
| pymysql | >=1.1.0 | 無重大漏洞 | 保持更新 |
|
||
| python-dateutil | >=2.8.2 | 無已知漏洞 | 保持更新 |
|
||
|
||
### 建議新增的安全相關套件
|
||
|
||
```
|
||
# requirements.txt 建議更新
|
||
scrapy>=2.11.0
|
||
itemadapter>=0.7.0
|
||
pymysql>=1.1.0
|
||
python-dateutil>=2.8.2
|
||
python-dotenv>=1.0.0 # 環境變數管理
|
||
DBUtils>=3.0.0 # 資料庫連線池(可選)
|
||
```
|
||
|
||
### 建議:設定依賴漏洞掃描
|
||
|
||
在 GitHub 倉庫啟用 Dependabot:
|
||
|
||
```yaml
|
||
# .github/dependabot.yml
|
||
version: 2
|
||
updates:
|
||
- package-ecosystem: "pip"
|
||
directory: "/"
|
||
schedule:
|
||
interval: "weekly"
|
||
open-pull-requests-limit: 5
|
||
```
|
||
|
||
---
|
||
|
||
## 第五部分:未來 Web 介面安全建議
|
||
|
||
由於您計畫新增 Web 介面,以下是預防性安全建議:
|
||
|
||
### 1. 身分驗證與授權
|
||
- 使用成熟的身分驗證框架(如 Flask-Login, FastAPI-Users)
|
||
- 實作 CSRF 保護
|
||
- 使用 JWT 或 Session-based 認證
|
||
- 實作速率限制防止暴力破解
|
||
|
||
### 2. API 安全
|
||
- 所有 API 端點使用 HTTPS
|
||
- 實作 CORS 限制
|
||
- 驗證所有輸入參數
|
||
- 使用 API Key 或 OAuth 進行認證
|
||
|
||
### 3. XSS 防護
|
||
- 對所有輸出進行 HTML 轉義
|
||
- 使用 Content-Security-Policy 標頭
|
||
- 驗證和清理所有用戶輸入
|
||
|
||
### 4. 資料庫查詢
|
||
- 繼續使用參數化查詢
|
||
- 實作查詢結果分頁(防止大量資料洩漏)
|
||
- 限制返回欄位(不返回敏感欄位)
|
||
|
||
---
|
||
|
||
## 修復優先順序
|
||
|
||
### 🔴 立即修復 (今天就要做)
|
||
|
||
1. **HIGH-001**: 從所有原始碼中移除硬編碼的密碼,改用環境變數
|
||
2. **HIGH-002**: 更換資料庫密碼為強密碼
|
||
3. **HIGH-003**: 設定資料庫存取限制(IP 白名單或 VPN)
|
||
|
||
### 🟠 本週修復
|
||
|
||
4. **MEDIUM-001**: 建立 .gitignore 檔案
|
||
5. **MEDIUM-002**: 修復 `create_database` SQL 注入風險
|
||
6. **MEDIUM-003**: 啟用資料庫 SSL/TLS 連線
|
||
7. **MEDIUM-004**: 改善錯誤處理,避免資訊洩漏
|
||
|
||
### 🟢 可排程修復
|
||
|
||
8. **LOW-001**: 實作資料庫連線池
|
||
9. **LOW-002**: 加強 GitHub Actions 安全設定
|
||
10. **LOW-003**: 加入輸入驗證和資料清理
|
||
|
||
---
|
||
|
||
## 結語
|
||
|
||
本次審計發現了幾個需要立即修復的嚴重安全問題,最關鍵的是 **資料庫密碼硬編碼** 和 **弱密碼** 問題。由於資料庫伺服器可從公網訪問,這些問題的風險被大幅放大。
|
||
|
||
好消息是,您的程式碼在其他方面相對安全:
|
||
- ✅ 使用參數化查詢(大部分情況)
|
||
- ✅ 遵守 robots.txt
|
||
- ✅ 使用 Gmail App Password 而非一般密碼
|
||
- ✅ 合理的程式碼結構
|
||
|
||
請按照優先順序儘快修復這些問題,特別是在將專案部署到生產環境之前。
|
||
|
||
---
|
||
|
||
**報告結束**
|
||
|
||
如有任何問題,請隨時詢問。
|