简介:这份资源面向网络安全初学者与机器学习实践者,聚焦钓鱼网站与钓鱼邮件的自动识别问题,提供一套可运行的建模方案。压缩包共14个文件,以10个Python脚本和4个CSV数据集为主,整体约339KB,脚本覆盖数据预处理、特征提取、模型定义与训练流程,CSV则用于存放钓鱼及正常样本,便于直接复现实验。内容围绕URL与邮件文本的特征工程展开,涉及TF-IDF、Word2Vec等文本编码方式,并给出朴素贝叶斯、支持向量机、随机森林及多种神经网络模型的实现思路,其中包含CNN、LSTM与CNN-LSTM等深度学习结构,可用于对比不同算法在钓鱼检测任务上的表现。已有241人学习,适合希望理解钓鱼攻击特征、掌握从数据收集到模型评估完整链路的学习者,也可作为课程设计或安全类项目的参考脚本,帮助快速搭建基线模型并在此基础上做特征与超参数调优。
1. 从一封可疑邮件说起:这个模型到底在防什么
做后端开发的,大概率都遇到过这种需求:运营同学转来一封邮件,说“这链接看着不对劲,你帮忙看看是不是钓鱼的”。点开一看,域名拼写差一个字母,页面做得跟真的一模一样,输入框就等着你填账号密码。人工判断一次两次还行,量一上来根本扛不住。这份「建立识别钓鱼网站(邮件)的机器学习模型.zip」要解决的,就是把这种判断从“靠经验”变成“靠模型”——输入一封邮件里的 URL 和文本特征,输出一个钓鱼概率。
它适合两类人:一类是安全方向刚入门、想找一个完整可跑的分类项目练手的开发者;另一类是在公司里真要做邮件网关、反钓鱼告警的工程同学,需要一套能改特征、能换模型的基线代码。整个项目走的是经典的监督学习路线,特征工程 + 模型训练 + 评估落盘,不依赖重型框架,本地就能跑通。下面我按“这项目怎么搭起来 → 怎么跑 → 怎么改”的顺序拆一遍。
2. 项目结构与技术选型:为什么是这套组合
2.1 目录长什么样
拿到压缩包解压后,常见的目录组织是这样一套结构,我按实际拆包经验还原一下:
phishing-detector/ ├── data/ │ ├── raw/ # 原始邮件/URL 数据集 │ └── processed/ # 清洗、特征提取后的中间文件 ├── src/ │ ├── preprocess.py # 数据清洗与特征提取 │ ├── features.py # 特征工程函数集合 │ ├── train.py # 模型训练入口 │ ├── evaluate.py # 评估与混淆矩阵输出 │ └── predict.py # 单条样本推理 ├── models/ # 训练好的模型文件(.pkl / .joblib) ├── configs/ │ └── config.yaml # 路径、超参数、特征开关 ├── requirements.txt └── README.md这个划分的好处是职责清晰:preprocess.py只管把脏数据变成干净表格,features.py只管从 URL 和邮件正文里抠特征,train.py只负责读特征、训模型、存模型。二次开发时你想换特征,只动features.py;想换模型,只动train.py里那几行。这种“特征与训练解耦”的写法,是这类项目最值得保留的设计。
2.2 技术栈为什么选 scikit-learn 而不是深度学习
项目核心依赖是pandas、numpy、scikit-learn,外加nltk或jieba做文本处理,joblib做模型持久化。为什么不上 BERT 这类大模型?因为钓鱼识别这个场景,强特征比强模型更管用。URL 长度、是否含 IP、子域名层数、特殊字符数量、是否用短链、邮件正文里的紧迫性词汇——这些特征本身就极具区分度,用逻辑回归或随机森林就能拿到不错的指标,训练快、可解释、部署轻。
常见做法是先用RandomForestClassifier或GradientBoostingClassifier打基线,再考虑要不要上XGBoost。我一般会先跑逻辑回归看特征权重,确认哪些特征真的在起作用,再换树模型提指标。这样调参的时候心里有数,不至于模型是个黑匣子。
2.3 配置文件驱动的参数管理
configs/config.yaml是这套代码的“后悔药”。把数据路径、测试集比例、随机种子、模型类型、树的数量都写进配置,训练脚本读配置执行。好处是复现实验时不用改代码,只改 yaml:
data: raw_path: "data/raw/emails.csv" processed_path: "data/processed/features.csv" test_size: 0.2 random_state: 42 features: use_url_features: true use_text_features: true max_text_features: 500 model: type: "random_forest" n_estimators: 200 max_depth: 20random_state固定住,是为了每次跑出来的划分一致,不然指标忽高忽低,排查问题时会怀疑人生。max_text_features控制文本向量化的维度上限,防止词表爆炸把内存吃满。
3. 特征工程拆解:钓鱼 URL 和邮件文本怎么变成数字
3.1 URL 结构化特征提取
这是整个项目最核心的部分。模型能不能识别钓鱼,八成看特征提得对不对。下面这段是features.py里 URL 特征提取的典型实现:
import re from urllib.parse import urlparse def extract_url_features(url: str) -> dict: """从单条 URL 中提取结构化特征""" features = {} parsed = urlparse(url) host = parsed.netloc path = parsed.path # 1. URL 总长度:钓鱼链接往往偏长,用来藏真实域名 features['url_length'] = len(url) # 2. 主机名长度 features['host_length'] = len(host) # 3. 是否直接用 IP 当域名,正常站点极少这么干 features['has_ip'] = 1 if re.match(r'\d+\.\d+\.\d+\.\d+', host) else 0 # 4. 子域名层数:a.b.c.evil.com 这种层层嵌套是典型手法 features['subdomain_depth'] = host.count('.') # 5. 特殊字符数量:@ 用于混淆,- 用于仿冒品牌名 features['count_at'] = url.count('@') features['count_hyphen'] = host.count('-') features['count_dot'] = url.count('.') # 6. 是否使用 HTTPS,钓鱼站也常挂证书,但仍是弱信号 features['is_https'] = 1 if parsed.scheme == 'https' else 0 # 7. 路径里是否含敏感词,诱导登录 sensitive_words = ['login', 'verify', 'account', 'update', 'secure'] features['sensitive_in_path'] = int( any(w in path.lower() for w in sensitive_words) ) # 8. 是否短链,短链会隐藏真实跳转目标 shorteners = ['bit.ly', 't.cn', 'goo.gl', 'tinyurl'] features['is_shortener'] = int(any(s in host for s in shorteners)) return features逐条说下逻辑:url_length和host_length是最基础的统计量,钓鱼链接为了把真实域名藏在后面,往往整体偏长;has_ip抓的是直接用 IP 访问的情况,正规业务几乎不会这么干;subdomain_depth用点号数量近似子域名层数,paypal.com.security-check.xyz这种就是典型仿冒;count_at是因为@在 URL 里会让浏览器忽略前面的内容,攻击者用它伪装;sensitive_in_path和is_shortener是业务经验特征,前者抓诱导登录,后者抓跳转隐藏。
参数上,sensitive_words和shorteners这两个列表是最该按你实际数据调整的地方。不同行业被仿冒的关键词不一样,金融类要加bank、pay,电商类要加order、refund。我一般会先统计正负样本里高频出现的路径词,再决定往列表里加什么,而不是拍脑袋。
3.2 邮件文本特征与向量化
URL 之外,邮件正文和主题也是重要信号。钓鱼邮件通常有固定的语气模式:制造紧迫感、要求立即操作、称呼模糊。这部分用 TF-IDF 做向量化:
from sklearn.feature_extraction.text import TfidfVectorizer import pandas as pd def build_text_features(texts: pd.Series, max_features: int = 500): """把邮件正文转成 TF-IDF 稀疏矩阵""" vectorizer = TfidfVectorizer( max_features=max_features, # 限制词表大小,控制内存 ngram_range=(1, 2), # 同时看单词和双词组合 stop_words='english', # 去掉 the/a 这类无信息词 min_df=3, # 至少出现在 3 篇里才保留,过滤噪声 lowercase=True ) matrix = vectorizer.fit_transform(texts) return matrix, vectorizerngram_range=(1, 2)是关键,因为“verify account”这种双词组合比单独的“verify”更有判别力。min_df=3过滤掉只出现一两次的稀有词,避免模型记住噪声。max_features=500是内存和效果的折中,数据量大可以往上调,但要注意稀疏矩阵维度。
文本特征和 URL 特征最后用scipy.sparse.hstack横向拼接,形成完整特征矩阵。这里有个坑:TF-IDF 是稀疏矩阵,URL 特征是稠密数组,拼接前要统一格式,否则会报维度或类型错误。
3.3 标签与数据划分
标签一般是二分类,1表示钓鱼,0表示正常。划分时用分层抽样保证正负比例一致:
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y # 按标签比例分层,防止某类样本全跑测试集 )stratify=y这行别省。钓鱼样本通常远少于正常样本,不分层的话测试集里可能一条钓鱼都没有,评估结果完全失真。
4. 训练、评估与推理:把模型跑起来
4.1 训练脚本主流程
train.py的主流程就是把前面几步串起来,读配置、提特征、训模型、存文件:
import yaml import joblib import pandas as pd from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report from src.features import extract_url_features, build_text_features from sklearn.model_selection import train_test_split # 读配置 with open('configs/config.yaml') as f: cfg = yaml.safe_load(f) # 读原始数据 df = pd.read_csv(cfg['data']['raw_path']) # URL 特征:逐行提取后转成 DataFrame url_feats = df['url'].apply(extract_url_features).apply(pd.Series) # 文本特征 text_matrix, vectorizer = build_text_features( df['body'], cfg['features']['max_text_features'] ) # 拼接特征(稀疏 + 稠密) from scipy.sparse import hstack, csr_matrix X = hstack([csr_matrix(url_feats.values), text_matrix]) y = df['label'].values # 划分 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=cfg['data']['test_size'], random_state=cfg['data']['random_state'], stratify=y ) # 训练 clf = RandomForestClassifier( n_estimators=cfg['model']['n_estimators'], max_depth=cfg['model']['max_depth'], random_state=cfg['data']['random_state'], n_jobs=-1 ) clf.fit(X_train, y_train) # 评估 pred = clf.predict(X_test) print(classification_report(y_test, pred)) # 持久化模型和向量器,推理时要一起加载 joblib.dump(clf, 'models/phishing_clf.pkl') joblib.dump(vectorizer, 'models/tfidf_vectorizer.pkl')几个参数说明:n_jobs=-1让随机森林用满所有 CPU 核,训练能快好几倍;max_depth=20是防止树长得太深过拟合,数据量小的时候可以降到 10 左右;模型和向量器必须一起存,因为推理时新文本要用同一个向量器转换,否则特征空间对不上,预测结果全是错的。
4.2 评估指标怎么看
classification_report会输出 precision、recall、f1。钓鱼识别这个场景,recall 比 precision 更值得盯。漏掉一个钓鱼邮件(假阴性)可能意味着用户账号被盗,误报一个正常邮件(假阳性)只是多一次人工复核。所以调参时优先把钓鱼类的 recall 拉高,哪怕 precision 掉一点也能接受。
如果数据极度不平衡,光看 accuracy 会骗人——全预测成正常也有 95% 准确率。这时候要看混淆矩阵和 AUC。项目里evaluate.py一般会输出混淆矩阵,四个格子分别对应真阳、假阳、假阴、真阴,假阴那一格就是漏报数,是重点优化对象。
4.3 单条推理怎么用
predict.py负责线上推理,输入一条 URL 和邮件正文,输出概率:
import joblib import pandas as pd from scipy.sparse import hstack, csr_matrix from src.features import extract_url_features clf = joblib.load('models/phishing_clf.pkl') vectorizer = joblib.load('models/tfidf_vectorizer.pkl') def predict_one(url: str, body: str) -> float: """返回钓鱼概率,越接近 1 越可疑""" url_feat = pd.Series(extract_url_features(url)).to_frame().T text_feat = vectorizer.transform([body]) X = hstack([csr_matrix(url_feat.values), text_feat]) prob = clf.predict_proba(X)[0][1] # 取类别 1(钓鱼)的概率 return prob if __name__ == '__main__': p = predict_one( "http://paypal-secure-login.xyz/verify", "Your account will be suspended, verify immediately!" ) print(f"钓鱼概率: {p:.4f}")predict_proba返回的是两个概率,索引[1]才是钓鱼类。实际部署时不会只输出概率,一般会设个阈值,比如超过 0.7 就告警,0.4 到 0.7 之间进人工复核队列。阈值定多少,取决于你能承受多少误报,这个得跟业务方对齐。
5. 避坑与常见问题:跑不通时先看这几条
5.1 特征拼接报维度错误
现象:hstack那行抛ValueError: blocks must have the same number of rows。原因通常是 URL 特征提取时某条数据返回了空字典,pd.Series展开后行数对不上。解决:在extract_url_features里保证任何输入都返回完整键,缺的填 0;或者在拼接前assert url_feats.shape[0] == text_matrix.shape[0]先卡一道。
5.2 训练指标很高但线上全错
现象:测试集 f1 0.95,上线后预测结果离谱。原因多半是训练和推理用了不同的特征处理流程——训练时用了某个向量器,推理时重新 fit 了一个,词表不一致。解决:向量器必须持久化,推理时load同一个,绝不能重新fit。这是血泪经验,我见过不止一个项目栽在这。
5.3 正负样本极度不平衡
现象:模型把所有样本都预测成正常,accuracy 还很高。原因:钓鱼样本占比太低,模型学到了“全猜多数类”这个偷懒策略。解决:训练时加class_weight='balanced',或者对少数类做上采样;评估时别看 accuracy,看 recall 和 AUC。
5.4 中文邮件文本处理乱码
现象:中文邮件正文向量化后特征全是乱码或空。原因:TfidfVectorizer默认按空格分词,中文没有空格。解决:换成jieba分词后传入,或者用TfidfVectorizer(tokenizer=jieba.lcut)。这一步不做,中文数据基本白给。
5.5 模型文件加载报版本不兼容
现象:本地训好的.pkl换台机器加载报InconsistentVersionWarning或直接失败。原因:scikit-learn 版本不一致,pickle 序列化对版本敏感。解决:requirements.txt里把scikit-learn版本钉死,训练和部署环境用同一个版本。别用latest,这是踩坑重灾区。
6. 二次开发:换特征、换模型、接服务
6.1 加特征的正确姿势
想提升效果,最划算的是加特征,不是换模型。比如加“域名注册时长”(需要调 WHOIS,但可以离线缓存)、“URL 是否含 punycode 编码”(xn--开头,用于仿冒同形字)、“邮件发件人域名与链接域名是否一致”。加特征时在features.py里新增函数,在extract_url_features里挂上,然后重跑训练看指标变化。每次只加一组,对比着看,别一次加十个,不然不知道哪个起作用。
6.2 换模型的对比方法
train.py里模型那几行可以抽成一个工厂函数,按配置切换:
from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier def build_model(cfg): t = cfg['model']['type'] if t == 'logistic': return LogisticRegression(max_iter=1000, class_weight='balanced') elif t == 'random_forest': return RandomForestClassifier( n_estimators=cfg['model']['n_estimators'], max_depth=cfg['model']['max_depth'], class_weight='balanced', n_jobs=-1 ) elif t == 'gbdt': return GradientBoostingClassifier( n_estimators=cfg['model']['n_estimators'], learning_rate=0.1 ) raise ValueError(f"未知模型类型: {t}")换模型时保持特征和划分不变,只改config.yaml里的type,跑三遍对比 recall 和 AUC。逻辑回归可解释性最好,能看系数判断特征重要性;随机森林稳,适合打基线;GBDT 指标通常最高但训练慢。我一般先用逻辑回归确认特征有效,再用随机森林定基线,最后看 GBDT 能不能再提一两个点。
6.3 接成 HTTP 服务
要真用起来,得包一层 API。用 FastAPI 起个服务,加载一次模型常驻内存:
from fastapi import FastAPI from pydantic import BaseModel import joblib from src.predict import predict_one app = FastAPI() clf = joblib.load('models/phishing_clf.pkl') vectorizer = joblib.load('models/tfidf_vectorizer.pkl') class EmailReq(BaseModel): url: str body: str @app.post("/predict") def predict(req: EmailReq): prob = predict_one(req.url, req.body) return {"phishing_prob": prob, "is_phishing": prob > 0.7}注意模型加载放在模块顶层,只加载一次。如果放在请求处理函数里,每个请求都读一次磁盘,QPS 直接崩。阈值0.7建议做成配置项,方便按业务反馈调整。
6.4 一个我踩过的坑
有次我图省事,把特征提取逻辑在训练脚本和推理脚本里各写了一份,想着“就几行,复制一下快”。结果改特征时只改了训练那份,推理那份还是旧的,线上预测全乱,排查了大半天才发现是两份代码不一致。从那以后我每次做这类项目,特征提取函数都强制只留一份,训练和推理都从同一个模块 import,改一处全局生效。这个习惯帮我省了无数次返工。
如果你拿到这份资源,建议先按 README 把基线跑通,确认指标正常,再动特征和模型。跑通之前别急着改代码,不然出了问题分不清是环境问题还是改动引入的。希望帮到你。
本文还有配套的精品资源,点击获取