做业务系统的人,八成收到过安全部门发来的通知:某个上传模块里发现了客户手机号,请立即排查。我第一次接到这种通报时,第一反应是收紧上传文件的扩展名白名单,改成只能传图片和PDF,结果没撑过两周,第二封通报就来了——有人把docx后缀改成jpg传上来,系统照样收货不误。从那以后我明白了一个道理:要防的不是格式,是内容本身。与其跟文件后缀较劲,不如做一套能读内容、认敏感信息的服务。
这个方案最终落地的形态,就是基于SpringBoot和Tika搭建的数据泄露防护(DLP)服务。Tika负责把docx、pdf、xlsx、压缩包里的文本内容抽出来,SpringBoot负责对外提供上传、检测、告警的接口,再加上一层基于规则引擎的敏感信息匹配,身份证号、手机号、银行卡号这类高敏数据一旦在文件内容里出现,系统就能自动识别、拦截或脱敏。这篇文章把我实际搭建过程中踩过的坑、调整过的设计、最后验证过的代码完整记录下来,适合正在做文件上传场景、想要补上内容安全检测能力的后端开发者,也适合安全团队想快速做一个内部检测Demo的同事参考。
1. 先想清楚:这个系统到底要检测什么,怎么检测
1.1 DLP需求里的隐藏场景
数据泄露防护这个词听起来偏安全基建,但落到业务系统里,其实就是几个非常具体的场景。最典型的,工单系统的附件里经常塞简历、合同扫描件、身份证照片;内容管理平台的上传入口每年要处理成百上千份文档,其中夹杂客户通讯录;甚至内部知识库里有人直接传了一份包含员工薪资和手机号的Excel。这些文件一旦被外部访问到,或者被爬虫抓走,就是实打实的泄露事件。
我的第一版方案是拦截敏感文件的扩展名,比如禁止上传xlsx、禁止上传docx,结果业务部门强烈反对。改后缀上传这个操作成本几乎为零,Windows里重命名一下就绕过了。这说明扩展名拦截只能防君子,防不了所有情况,必须做内容级检测。
内容级检测的技术路线其实很清晰:能读文件里的文本,才能判断文本里有没有敏感信息。但问题马上就来了,业务系统里的文件格式五花八门,docx的内部结构是zip,pdf有对象流和压缩流,xlsx本质是打包的XML,老旧的WPS格式又是另一套东西。如果为每种格式单独写解析器,光维护文档格式兼容性就够喝一壶了。我最初试着用POI解析docx和xlsx,再单独用PDF库解析PDF,代码写了不到两百行,已经发现格式兼容性是个无底洞:同样是docx,不同WPS版本生成的文件结构还有细微差异,有的解析器直接抛异常。
1.2 为什么选Tika而不是自己写解析
Tika是Apache下的内容检测工具包,本质上是一大堆文档格式解析器(Parser)的集合,它对外提供统一的文本抽取接口。我只需要把一个InputStream交给它,它就能自动识别文件类型,选对应的解析器把文本内容抠出来。这在DLP场景里价值很高,因为我不需要关心上游业务系统传上来的是什么格式,Tika会自己去识别和处理。
Tika还有一个特性非常适合DLP场景,就是它能根据文件内容的二进制特征识别真实格式,而不是依赖扩展名。也就是说,那个把docx改成jpg的绕过技巧,在Tika面前无效。Tika检测文件头部魔数后,能识别出这是一个Word文档而不是JPEG图片,我们就能据此拒绝解析异常的文件。
我对比过两条路。一条是引入POI、PDFBox等解析库挨个写逻辑,优点是格式细节可控,缺点是每种格式都有各自的API、异常处理、加密策略,代码量成倍增长,后续维护成本很高。另一条就是Tika的AutoDetectParser,一个Parser接口兼容几十种格式。对DLP这类以文本匹配为核心的场景来说,选择Tika明显更符合工程性价比。
1.3 架构设计:一条检测流水线三个关键决策
整个检测服务最终做成了一条流水线,从文件上传到处置完成,依次经过:文件大小校验、真实类型识别、文本抽取、敏感规则匹配、命中处置。这个流水线本身不复杂,但设计时有三个关键决策,直接决定了后续代码的结构。
第一个决策是统一走“先抽文本、再匹配规则”的两步模式,而不是对每种文件格式单独写规则。这个决策把问题域做了很好的分割:文本抽取是Tika的事,敏感匹配是我们自己的规则引擎。以后加新的敏感类型,只需要在规则引擎里加规则,不需要动解析层。第二个决策是敏感检测以正则为主、校验算法为辅,纯正则匹配会把一串看起来像手机号的数字全部标红,误报率高到业务没法用,后面会细说。第三个决策是命中处置策略必须可配置,同一个文件,在面向公众的平台上可能是直接拦截,在内部系统里可能只是告警加脱敏展示,让策略跟着场景走,而不是写死在代码里。
2. 从零搭建:项目配置与基础方案落地
2.1 依赖引入与版本踩坑
项目基于SpringBoot 2.7搭建,JDK用的11。Tika方面引入两个依赖,一个核心包,一个解析器标准包。这是第一个坑:很多旧教程还在让人引tika-parsers,但在Tika 2.x里,这个包已经拆分了,直接引tika-parsers会因为找不到类而报错。正确做法是引标准解析器包tika-parsers-standard-package,它涵盖了绝大多数常见格式的解析能力。
<dependency> <groupId>org.apache.tika</groupId> <artifactId>tika-core</artifactId> <version>2.9.0</version> </dependency> <dependency> <groupId>org.apache.tika</groupId> <artifactId>tika-parsers-standard-package</artifactId> <version>2.9.0</version> </dependency>版本这块我建议锁定一个自己验证过的稳定版本,而不是直接追最新。Tika的迭代速度不算慢,新版本可能会调整某个解析器的行为,哪怕只是升级了底层库,也可能在慢慢蚕食你已有的兼容性覆盖。我一开始用了当时的最新版,结果解析某老版本WPS生成的doc文件抛异常,后来换回自己验证过的2.9.0才稳定,从那以后都是锁版本不轻易动。
同时引入SpringBoot的Web和Validation包,这里不再赘述。如果项目里已经引入了其他文档解析库,注意依赖冲突问题,POI的版本和Tika内部管理的POI版本容易撞,实际项目里我就遇到过一次NoSuchMethodError,排查半天发现是POI版本被Tika传递依赖覆盖了,最终方案是把业务代码里对POI的直接依赖去掉,统一走Tika。
2.2 文本抽取服务封装
Tika的使用姿势有两种。一种是直接用门面类new Tika().parseToString(inputStream),方式非常简单,几行代码就能拿到文本。但我不推荐在生产环境直接这么用,因为门面类把底层细节全藏着,没法限制解析输出的大小,也没法拿到Metadata里的真实文件类型。恶意用户上传一个几百MB的超大文本,一旦解析器把全部内容读进内存,服务很快就会被拖垮。
所以我的代码里选择了底层一点的AutoDetectParser组合WriteOutContentHandler的方式,显式控制抽取出来的文本上限。下面是完整的文本抽取服务:
@Service public class TextExtractionService { private static final int MAX_STRING_SIZE = 50000; private final AutoDetectParser parser = new AutoDetectParser(); public String extract(InputStream in, Metadata metadata) throws Exception { ParseContext context = new ParseContext(); context.set(Parser.class, parser); WriteOutContentHandler handler = new WriteOutContentHandler(MAX_STRING_SIZE); parser.parse(in, handler, metadata, context); return handler.toString(); } public String detectType(InputStream in, Metadata metadata) throws IOException { return parser.getDetector().detect(in, metadata).toString(); } }这段代码有两个需要特别说明的点。第一,WriteOutContentHandler(MAX_STRING_SIZE)限制了文本抽取的最大字符数,超过会抛WriteOutContentHandler.TooBigException,这是个保护措施,防止恶意超大文档打爆内存。50000字符对敏感信息检测来说已经足够,正常简历、合同、邮件正文基本都覆盖到了。第二,Metadata这个对象负责承载文件的元数据,我们在调用前可以往里放文件名作为提示,解析后可以从里面取CONTENT_TYPE拿真实格式。
detectType方法是用来做真实类型识别的,它调用了Tika的Detector,基于文件内容特征判断类型,而不是信任扩展名。这里要注意,如果用同一个InputStream先调了detect再调parse,流的指针已经到头了,解析会拿不到内容。我在项目里踩过这个坑,解决方案是把流先包成TikaInputStream,它能支持重复读,或者干脆从MultipartFile里重新获取一个新的流。
2.3 敏感规则引擎设计
规则引擎是整个检测链路的灵魂,也是这里最容易让人翻车的地方。我第一版只用了简单正则,很快就被误报折磨到怀疑人生。一轮测试下来,一份正常的Excel表格里有几十个被标红的数字串,业务同事看完直接拒绝验收。后来把规则引擎升级成三层匹配:
第一层是格式正则,负责把候选字符串找出来。第二层是校验算法,对特定类型的候选值做数学校验,比如身份证的校验位、银行卡的Luhn算法,这层能过滤掉大量形态相似但其实不合法的数字串。第三层是上下文规则,结合文件里的上下文特征做二次判断,比如手机号后面跟着“联系人”“电话”这类词时才计入高置信命中。
我实际使用的核心正则和校验方法如下:
public final class SensitiveRule { // 手机号:1开头,第二位3-9,后9位数字,前后不能紧跟数字 public static final Pattern MOBILE_PATTERN = Pattern.compile("(?<!\\d)1[3-9]\\d{9}(?!\\d)"); // 身份证:17位数字+数字或X,前后不能紧跟数字 public static final Pattern ID_CARD_PATTERN = Pattern.compile("(?<!\\d)\\d{17}[0-9Xx](?!\\d)"); // 银行卡:16-19位数字,前后不能紧跟数字,需配合Luhn校验 public static final Pattern BANK_CARD_PATTERN = Pattern.compile("(?<!\\d)\\d{16,19}(?!\\d)"); // 邮箱:简化版,符合常见邮箱形态 public static final Pattern EMAIL_PATTERN = Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"); }注意看手机号正则里的(?<!\d)和(?!\d),这是零宽断言,保证匹配到的数字串前后不是数字。别小看这两个断言,没有它们,一份文档里连续抄写的18位订单号,中间截出一段11位的子串就会被误判成手机号。银行卡正则同理,但是16到19位纯数字的匹配面太宽,必须用Luhn算法兜底。
public static boolean luhnCheck(String cardNo) { if (cardNo == null || cardNo.length() < 15 || cardNo.length() > 19) { return false; } int sum = 0; boolean doubleDigit = false; for (int i = cardNo.length() - 1; i >= 0; i--) { int digit = cardNo.charAt(i) - '0'; if (digit < 0 || digit > 9) { return false; } if (doubleDigit) { digit *= 2; if (digit > 9) { digit -= 9; } } sum += digit; doubleDigit = !doubleDigit; } return sum % 10 == 0; }身份证校验位算法大家比较熟悉,权重数组配合模11映射,我不再贴代码。这里要强调一个经验:加了校验算法之后,误报率至少降一个数量级。我拿内部脱敏数据做了一次回归测试,银行卡规则加Luhn校验前,准确率不到百分之三十,加完之后准确率提升到百分之九十五以上。正则只能保证“长得像”,校验算法才能保证“算得上”。
3. 核心检测链路编码实战
3.1 上传入口与参数校验
Controller层对外暴露上传检测接口,接收MultipartFile,第一步不是解析,而是校验大小和文件是否为空。上传大小的上限根据业务场景设定,我这里设了20MB,SpringBoot里通过配置项控制:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MB入口校验这个小细节值得多说一句。如果不在Controller层拦截,一个超大文件会直接进入Tika解析流程,解析前要探测类型、要创建临时存储,消耗大量内存和磁盘,成本远超拦截本身。所以我在接口最开始就是if (file.isEmpty() || file.getSize() > MAX_SIZE)直接驳回。
Controller代码很简单:
@RestController @RequestMapping("/api/dlp") public class DlpController { @PostMapping("/detect") public Result<DetectionResult> detect(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("空文件"); } if (file.getSize() > MAX_FILE_SIZE) { return Result.error("文件过大"); } DetectionResult result = detectionService.detect(file); return Result.success(result); } }这里有两个隐藏的重要细节。第一,DetectionResult返回给调用方时,绝不能携带完整的原始命中文本文本,只能携带脱敏后的文本片段,不然检测接口本身就变成了一个批量导出敏感信息的后台通道。第二,接口返回的响应结构要同时包含命中类型和命中位置,方便前端在文件详情页展示预警标签,也方便审计系统接入。我的响应结构大致是:
{ "hit": true, "rules": ["MOBILE", "ID_CARD"], "message": "检测到2类敏感信息", "maskedSnippets": [ "客户联系方式:138****0000,请勿外传" ] }3.2 文件真实格式识别与解析
业务层DetectionService承担了主要工作,流程是:拿到MultipartFile的输入流,先用detectType识别真实格式,再抽取文本,最后喂给规则引擎。
@Service public class DetectionService { private final TextExtractionService extractionService; private final SensitiveRuleEngine ruleEngine; public DetectionResult detect(MultipartFile file) throws Exception { Metadata metadata = new Metadata(); metadata.set(Metadata.RESOURCE_NAME_KEY, file.getOriginalFilename()); try (InputStream rawIn = file.getInputStream()) { TikaInputStream tikaIn = TikaInputStream.get(rawIn); String realType = extractionService.detectType(tikaIn, metadata); if (!supportType(realType)) { return DetectionResult.unsupported(realType); } String text = extractionService.extract(tikaIn, metadata); return ruleEngine.evaluate(text, realType); } } }这段代码里,TikaInputStream.get(rawIn)是处理流重读问题的关键。先说一个现象,我在第一版时直接用原始InputStream先调detect再调parse,检测结果一直是空文本,排查到后来发现是流被消费完了,第二个方法拿到的是一个空流。后来把输入流包成TikaInputStream,它能基于临时文件缓冲内容,支持重复读取,问题就解决了。
supportType方法里需要过滤掉那些拿到文本也没有检测意义的类型,比如图片、音频、视频。这里的处理策略是:拒绝直接放行,但也不拦截,而是返回一个“跳过检测”的状态,让上层业务决定是放行还是转人工。图片里的文字,比如身份证照片、截图里的聊天记录,Tika不会主动做OCR,如果业务场景有硬需求,得单独接OCR模块,这是后话。
SensitiveRuleEngine.evaluate是规则引擎的入口,它会遍历注册好的规则集合,对抽取出来的文本逐条匹配,聚合结果。匹配过程中有一个性能细节:一旦某一个规则命中了,我会继续跑完所有规则,把命中的敏感类型集合返回给上层,因为告警内容需要知道“这份文件里有手机号又有身份证”,而不仅仅是“命中或不命中”,这对安全运营判断风险等级非常重要。
3.3 命中判定与脱敏展示
规则引擎的判定结果如果命中,需要进入处置模块。我在设计时抽象了一个DispositionHandler接口,有三种实现:直接拦截(返回错误码给调用方)、只告警(记录审计日志后放行)、脱敏展示(将命中内容打码后存入资料库)。用策略模式而不是if-else硬编码的原因很简单:不同文件类型在不同业务模块里的敏感性不同。简历里的手机号可能是正常求职信息,但客服中心导出的文件里出现手机号,几乎可以判定为不该上传的资料。
脱敏这件事值得单独说道说道。检测服务在返回命中文本片段时,要对手机号等敏感数据做掩码处理,方法很简单:
public static String maskMobile(String text) { return text.replaceAll("(?<!\\d)1[3-9]\\d{9}(?!\\d)", m -> m.group().substring(0, 3) + "****" + m.group().substring(7)); }这种脱敏必须在服务端完成,绝不能把原文返回给前端再脱敏。我见过有人图省事,把原始文本和命中位置一起返回,让前端打星号,结果前端渲染时如果出了bug,页面F12就能看到完整手机号,等于我们自己在给泄露打开一扇门。检测服务作为安全组件,自身必须是安全可信赖的。
异步处理也是这里的优化点。解析文本是一个耗时操作,一个20MB的docx解析出来可能需要几百毫秒,如果在请求线程里同步执行,高并发下接口吞吐会变得非常差。我后来在服务内部把检测流程放到了独立的线程池里,用CompletableFuture异步执行,主流程只负责把任务入队、快速返回“检测中”的状态。调用方通过轮询或回调获取最终检测结果。线程池要单独配置,不跟业务线程池混用,避免一个上传大文件的用户占满所有业务线程。
4. 常见问题与性能优化实录
4.1 解析失败的典型场景与应对
实测过程中,Tika解析的失败场景五花八门,这里列几个最有代表性的和处理建议。
加密文档是最常见的一种。Tika遇到带密码的docx、pdf时,会抛出EncryptedDocumentException或类似异常。这种情况下不能无脑拦截,可能是业务人员自己的密码文件。我的处理办法是捕获异常后返回一个“加密文件跳过检测”的状态,同时计入审计日志,由安全团队决定是否需要人工排查。
WPS老格式Doc也踩过坑。Tika的官方解析器对微软的OOXML格式支持得好,但对某些老版本WPS生成的格式,解析结果偶尔会丢内容或者乱码。我最后的处理方案是升级到标准解析器包,它内置了更多兼容解析器,实测下来老格式的文本抽取覆盖率大幅提升。如果你的环境里这类格式特别多,一定要做一轮样本回归测试。
扫描件PDF几乎抽不出文本,因为内容是图片,需要OCR。Tika本身不带OCR能力,但它的设计允许通过OCR2Parser接入Tesseract。我没在生产环境上OCR,原因很简单:OCR对算力要求高,而且在低质量扫描件上错误率不低,直接放到DLP检测链路里,会拖慢整个流程。我目前的处理策略是对扫描件单独打标,走人工复核队列。
解析结果乱码的问题,多半出在编码识别上。Tika对常见编码识别率不低,但某些老旧格式里如果编码是GB18030变体,偶尔会出问题。我在封装抽取服务时,遇到过解析一个txt文件乱码的现象,排查后发现是文件本身存的编码跟系统默认编码不一致。解决方式是在Metadata里显式设置Metadata.CONTENT_ENCODING,或者用Reader的方式传字符流。做安全检测时,解析质量直接影响规则匹配的准确率,宁可多花一点时间处理编码问题,也不要等上线后被误报和漏报一起打脸。
4.2 高并发与内存控制
DLP服务上线后迎来的第一个大流量场景,是某个活动页面上传量激增,检测接口的RT肉眼可见地变长。这其实不是Tika慢,而是同步解析把线程池占满了。我把流程切换为异步后,接口响应从平均2.3秒降到了50毫秒左右,真正的检测任务被丢到后台队列里慢慢消化。这里的核心经验是:安全检测类组件,绝对不能阻塞用户主流程。
内存控制方面,WriteOutContentHandler的50000字符限制救了不少命。之前没有这个限制时,一个人恶意上传了几百MB的csv文件,Tika会把全量文本都拉进内存,服务的堆内存直接飙高,连续几次Full GC。设置字符上限后,超限的文档会被拒绝解析,虽然极端情况下也可能误伤超大日志文件,但相比服务崩溃的风险,这个取舍是值得的。
还有一个容易被忽视的点是临时文件的清理。TikaInputStream在包装大流时会在临时目录生成文件,用完不关,时间长了磁盘空间被占满。确保在用完TikaInputStream后调用close(),或者写在try-with-resources里。我在这上面吃过亏:测试环境跑了两个星期,某台磁盘使用率告警,排查半天发现临时目录里堆满了未关闭的Tika缓冲文件。
4.3 误报率调优经验
误报是DLP系统的头号公敌,误报太多会让运营人员对警报麻木,真正的泄露事件反而被淹没。我调误报的过程,本质上就是在“精确识别”和“不要漏报”中间反复走钢丝。有几个经验值得直接落地。
第一,正则宁可少写,也不要写宽。一个宽松的手机号正则可能匹配到电话号码、座机号、日期串。我后续把所有正则都加上了前后边界断言,宽匹配的数量直接少了三分之一。第二,能上校验算法的必须上。身份证校验位、银行卡Luhn算法,这两个校验能把误报率打到很低的水平。第三,引入白名单机制。规则引擎可以配置两类白名单,一类是号段白名单,比如某些企业内部测试用的号段;另一类是内容白名单,命中敏感规则但同时出现在白名单词库里的内容,可以直接放行。这些规则通过配置文件维护,业务团队可以自助调整,不会每次都来找开发改代码。
另外,规则引擎的输出值最好是带置信度的,而不是单纯是或否。我给每个规则设计了权重值,置信度达到一定阈值才判为命中。比如单独出现一个手机号,置信度是60%,如果同一段文本里同时出现手机号和姓名关键词“联系人”,置信度就推到85%。置信度机制让系统在单条规则误报的情况下,综合判断时不会误伤正常文件。
写在最后的实话
这类系统做起来不难,难在别把自己当安全研究专家,先当好一个务实的工程师。我最大的体会是:DLP系统永远没有“完全搞定”的那一天,文件格式在变、敏感信息的形态在变、业务的场景也在变。把Tika的解析、规则引擎的匹配、处置策略的配置这几层解耦干净,后面无论接入新的文件类型还是新增敏感规则,都不至于推倒重来。另一个值得投入精力的方向是把检测结果做成数据看板,按文件类型、敏感类型、命中率做统计分析,让安全团队能持续调优规则。这个系统上线半年后,我看后台数据才意识到,真正被拦截最多的敏感信息不是身份证,而是那种混在报表里的客户手机号成批流出,所以规则随时要跟着真实威胁走,不能闭门造车。