在當今互聯網環境下,數據脫敏對于國內的互聯網企業而言已經成為一項標配。這不僅是為了滿足合規性要求,更是保障用戶信息安全和企業聲譽的重要舉措。本文將深入探討技術派中實現數據脫敏的關鍵技術——通用敏感詞替換,從算法原理到具體實現,為你呈現一個全面的技術視角。
1 敏感詞校驗算法:DFA 算法
敏感詞校驗算法在數據脫敏中起著至關重要的作用。目前,相對成熟的算法有很多,其中 DFA(Deterministic Finite Automaton
,確定有限自動機)算法是一種在敏感詞校驗等領域常用且高效的算法,其基本原理如下:
-
構建樹狀查找結構(森林):
- 首先,基于給定的敏感詞庫來構建一個特殊的樹狀結構(嚴格來說,對于更完整的敏感詞庫構建出的結構,若不考慮根節點,是由多個樹結構組成的森林)。
- 例如,若敏感詞庫包含“我愛你”“我愛他”“我愛她”“我愛你呀”“我愛他呀”“我愛她呀”“我愛她啊”這些詞匯。在構建樹狀結構時,以這些詞匯的字符序列為路徑來構建節點關系。從根節點開始,第一個字符作為第一層節點的分支依據,第二個字符作為下一層節點的分支依據,以此類推,直到完整的敏感詞路徑構建完成。這樣,相同前綴的敏感詞會共享前面的節點路徑,形成一種層次化的樹狀結構。
-
字符串遍歷與匹配:
- 當有需要校驗的字符串輸入時,從字符串的第一個字符開始進行遍歷。
- 同時,設置一個指針指向樹狀結構的根節點。對于字符串中的每個字符,在樹狀結構中查找對應的子節點。如果能找到匹配的子節點,則將指針移動到該子節點;如果找不到,則表示從當前位置開始的字符序列不是敏感詞的一部分,繼續處理字符串的下一個字符,指針保持在當前位置(根節點或之前匹配到的節點)。
- 例如,對于輸入字符串“一灰我愛你呀哈哈哈”,從“一”開始遍歷,在樹狀結構中找不到匹配的以“一”開頭的敏感詞分支,指針保持在根節點;接著處理“灰”,同樣找不到匹配分支;當處理到“我”時,在樹狀結構中找到以“我”開頭的敏感詞分支,指針移動到對應的子節點;然后處理“愛”,繼續沿著匹配的路徑移動指針;當處理到“你”時,指針移動到相應節點,此時發現該節點是一個敏感詞“我愛你”的結束節點,就表示找到了一個敏感詞。然后繼續從下一個字符“呀”開始重復上述過程,直到遍歷完整個字符串。
-
高效性體現:
- DFA 算法的高效性在于,它通過預先構建好的樹狀結構,在對輸入字符串進行一次遍歷的過程中,就能快速確定其中是否包含敏感詞以及具體的敏感詞內容。相比于一些簡單的逐字符匹配算法(如暴力匹配算法),不需要對每個位置都進行大量的比較操作,大大減少了時間復雜度,提高了敏感詞校驗的效率,尤其適用于敏感詞庫較大、輸入字符串較長的場景。
通過以上步驟,DFA 算法實現了對輸入字符串中敏感詞的快速、準確校驗。
2 敏感詞服務類
為了在項目中更方便地使用敏感詞校驗功能,我們可以借助開源庫。這里從 GitHub 中選取了一個 star 較多的庫:https://github.com/houbb/sensitive-word 。
在技術派的項目中,使用該庫的方式如下:
- pom.xml文件引入依賴
<dependency><groupId>com.github.houbb</groupId><artifactId>sensitive-word</artifactId><version>${sensitive.version}</version>
</dependency>
- 新增一個敏感詞配置類,用于處理自定義的敏感詞以及白名單。
/*** 敏感詞相關配置,db配置表中的配置優先級更高,支持動態刷新*/
@Data
@Component
@ConfigurationProperties(prefix = "paicoding.sensitive")
public class SensitiveProperty {/*** true 表示開啟敏感詞校驗*/private Boolean enable;/*** 自定義的敏感詞*/private List<String> deny;/*** 自定義的非敏感詞*/private List<String> allow;
}
- 結合技術派實現的配置動態變更刷新機制([? 技術派實現自定義配置注入與動態刷新] ),封裝了支持敏感詞動態變更的服務類。
/*** 敏感詞服務類** @author YiHui* @date 2023/8/9*/
@Slf4j
@Service
public class SensitiveService {private SensitiveProperty sensitiveConfig;private SensitiveWordBs sensitiveWordBs;public SensitiveService(DynamicConfigContainer dynamicConfigContainer, SensitiveProperty sensitiveConfig) {this.sensitiveConfig = sensitiveConfig;dynamicConfigContainer.registerRefreshCallback(sensitiveConfig, this::refresh);}@PostConstructpublic void refresh() {IWordDeny deny = () -> {List<String> sub = WordDenySystem.getInstance().deny();sub.addAll(sensitiveConfig.getDeny());return sub;};IWordAllow allow = () -> {List<String> sub = WordAllowSystem.getInstance().allow();sub.addAll(sensitiveConfig.getAllow());return sub;};sensitiveWordBs = SensitiveWordBs.newInstance().wordDeny(deny).wordAllow(allow).init();log.info("敏感詞初始化完成!");}/*** 判斷是否包含敏感詞** @param txt* @return*/public boolean contains(String txt) {if (BooleanUtils.isTrue(sensitiveConfig.getEnable())) {return sensitiveWordBs.contains(txt);}return false;}/*** 敏感詞替換** @param txt* @return*/public String replace(String txt) {if (BooleanUtils.isTrue(sensitiveConfig.getEnable())) {return sensitiveWordBs.replace(txt);}return txt;}}
目前,敏感詞校驗主要應用在兩個部分:
-
派聰明的提問:對用戶輸入的提問內容進行敏感詞檢測和替換。
-
評價的敏感詞替換:基于 Mybatis 的插件機制,直接針對從數據庫查詢出來的評價字段進行敏感詞替換。
3 自定義的數據庫敏感詞替換方案
在實際生產項目中,為了安全和合規性,數據庫中有很多信息不能存儲明文,例如身份證、銀行卡等敏感信息,需要加密后存儲,讀取時再解密返回明文。實現這一過程有兩種常見方式:
- 直接編碼實現,每次寫和讀取數據時手動進行加解密操作。這種方式雖然簡單直接,但在實際應用中,尤其是在數據量較大、業務邏輯復雜的情況下,手動加解密容易出錯且維護成本高。
- 實現一個通用的解決方案,在需要脫敏的字段上加一個標識,然后在實際寫入數據庫或從數據庫讀取時,自動實現加解密。我們這里重點介紹的就是第二種方案。
4 基于 mybatis 攔截器的敏感詞替換實現方案
整體方案的思路較為清晰,具體步驟如下:
- 實現一個自定義注解,將其放置在需要脫敏的數據庫實體對象的成員上。通過這個注解來標識哪些字段需要進行敏感詞替換或數據脫敏處理。
- 實現查詢攔截器,當從數據庫返回內容到數據庫實體對象上時,判斷成員上是否有對應注解。如果有,則將該成員的值替換為敏感詞替換之后的內容。
在具體實現層面,為了提高性能,我們增加了緩存機制,減少每次都對實體對象的成員進行是否需要脫敏的判定。所有相關代碼可以在 com.github.paicoding.forum.core.senstive
下查看。
下面對幾個關鍵的實現進行詳細說明:
- 自定義注解:
com.github.paicoding.forum.core.senstive.ano.SensitiveField
。通過這個注解來標識哪些字段需要進行敏感詞替換或數據脫敏處理。
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
public @interface SensitiveField {/*** 綁定的db中的哪個字段** @return*/String bind() default "";}
- 攔截器實現:
com.github.paicoding.forum.core.senstive.ibatis.SensitiveReadInterceptor
。核心步驟包括根據返回結果找到對應的實體類,并確定需要進行替換的成員;然后執行具體的敏感詞替換操作。
/*** 敏感詞替換攔截器,這里主要是針對從db中讀取的數據進行敏感詞處理 (如果需要在寫入db時,進行脫敏如加密,也可以使用類似的方式來實現)*/
@Intercepts({@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {java.sql.Statement.class})
})
@Component
@Slf4j
public class SensitiveReadInterceptor implements Interceptor {private static final String MAPPED_STATEMENT = "mappedStatement";@Autowiredprivate SensitiveService sensitiveService;@SuppressWarnings("unchecked")@Overridepublic Object intercept(Invocation invocation) throws Throwable {final List<Object> results = (List<Object>) invocation.proceed();if (results.isEmpty()) {return results;}final ResultSetHandler statementHandler = realTarget(invocation.getTarget());final MetaObject metaObject = SystemMetaObject.forObject(statementHandler);final MappedStatement mappedStatement = (MappedStatement) metaObject.getValue(MAPPED_STATEMENT);Optional firstOpt = results.stream().filter(Objects::nonNull).findFirst();if (!firstOpt.isPresent()) {return results;}Object firstObject = firstOpt.get();SensitiveObjectMeta sensitiveObjectMeta = findSensitiveObjectMeta(firstObject);replaceSensitiveResults(results, mappedStatement, sensitiveObjectMeta);return results;}/*** 執行具體的敏感詞替換** @param results* @param mappedStatement* @param sensitiveObjectMeta*/private void replaceSensitiveResults(Collection<Object> results, MappedStatement mappedStatement, SensitiveObjectMeta sensitiveObjectMeta) {for (Object obj : results) {if (sensitiveObjectMeta.getSensitiveFieldMetaList() == null) {continue;}final MetaObject objMetaObject = mappedStatement.getConfiguration().newMetaObject(obj);sensitiveObjectMeta.getSensitiveFieldMetaList().forEach(i -> {Object value = objMetaObject.getValue(StringUtils.isBlank(i.getBindField()) ? i.getName() : i.getBindField());if (value == null) {return;} else if (value instanceof String) {String strValue = (String) value;String processVal = sensitiveService.replace(strValue);objMetaObject.setValue(i.getName(), processVal);} else if (value instanceof Collection) {Collection listValue = (Collection) value;if (CollectionUtils.isNotEmpty(listValue)) {Optional firstValOpt = listValue.stream().filter(Objects::nonNull).findFirst();if (firstValOpt.isPresent()) {SensitiveObjectMeta valSensitiveObjectMeta = findSensitiveObjectMeta(firstValOpt.get());if (Boolean.TRUE.equals(valSensitiveObjectMeta.getEnabledSensitiveReplace()) && CollectionUtils.isNotEmpty(valSensitiveObjectMeta.getSensitiveFieldMetaList())) {replaceSensitiveResults(listValue, mappedStatement, valSensitiveObjectMeta);}}}} else if (!ClassUtils.isPrimitiveOrWrapper(value.getClass())) {// 對于非基本類型的,需要對其內部進行敏感詞替換SensitiveObjectMeta valSensitiveObjectMeta = findSensitiveObjectMeta(value);if (Boolean.TRUE.equals(valSensitiveObjectMeta.getEnabledSensitiveReplace()) && CollectionUtils.isNotEmpty(valSensitiveObjectMeta.getSensitiveFieldMetaList())) {replaceSensitiveResults(newArrayList(value), mappedStatement, valSensitiveObjectMeta);}}});}}/*** 查詢對象中,攜帶有 @SensitiveField 的成員,進行敏感詞替換** @param firstObject 待查詢的對象* @return 返回對象的敏感詞元數據*/private SensitiveObjectMeta findSensitiveObjectMeta(Object firstObject) {SensitiveMetaCache.computeIfAbsent(firstObject.getClass().getName(), s -> {Optional<SensitiveObjectMeta> sensitiveObjectMetaOpt = SensitiveObjectMeta.buildSensitiveObjectMeta(firstObject);return sensitiveObjectMetaOpt.orElse(null);});return SensitiveMetaCache.get(firstObject.getClass().getName());}@Overridepublic Object plugin(Object o) {return Plugin.wrap(o, this);}@Overridepublic void setProperties(Properties properties) {}public static <T> T realTarget(Object target) {if (Proxy.isProxyClass(target.getClass())) {MetaObject metaObject = SystemMetaObject.forObject(target);return realTarget(metaObject.getValue("h.target"));}return (T) target;}
}
- 敏感詞替換元數據信息:
com.github.paicoding.forum.core.senstive.ibatis.SensitiveObjectMeta
。通過反射獲取數據庫實體對象的所有成員,判斷是否有自定義注解 SensitiveField ,如果有則記錄相關信息,以便后續進行替換操作。
/*** 敏感詞相關配置,db配置表中的配置優先級更高,支持動態刷新*/
@Data
public class SensitiveObjectMeta {private static final String JAVA_LANG_OBJECT = "java.lang.object";/*** 是否啟用脫敏*/private Boolean enabledSensitiveReplace;/*** 類名*/private String className;/*** 標注 SensitiveField 的成員*/private List<SensitiveFieldMeta> sensitiveFieldMetaList;public static Optional<SensitiveObjectMeta> buildSensitiveObjectMeta(Object param) {if (isNull(param)) {return Optional.empty();}Class<?> clazz = param.getClass();SensitiveObjectMeta sensitiveObjectMeta = new SensitiveObjectMeta();sensitiveObjectMeta.setClassName(clazz.getName());List<SensitiveFieldMeta> sensitiveFieldMetaList = newArrayList();sensitiveObjectMeta.setSensitiveFieldMetaList(sensitiveFieldMetaList);boolean sensitiveField = parseAllSensitiveFields(clazz, sensitiveFieldMetaList);sensitiveObjectMeta.setEnabledSensitiveReplace(sensitiveField);return Optional.of(sensitiveObjectMeta);}private static boolean parseAllSensitiveFields(Class<?> clazz, List<SensitiveFieldMeta> sensitiveFieldMetaList) {Class<?> tempClazz = clazz;boolean hasSensitiveField = false;while (nonNull(tempClazz) && !JAVA_LANG_OBJECT.equalsIgnoreCase(tempClazz.getName())) {for (Field field : tempClazz.getDeclaredFields()) {SensitiveField sensitiveField = field.getAnnotation(SensitiveField.class);if (nonNull(sensitiveField)) {SensitiveFieldMeta sensitiveFieldMeta = new SensitiveFieldMeta();sensitiveFieldMeta.setName(field.getName());sensitiveFieldMeta.setBindField(sensitiveField.bind());sensitiveFieldMetaList.add(sensitiveFieldMeta);hasSensitiveField = true;}}tempClazz = tempClazz.getSuperclass();}return hasSensitiveField;}@Datapublic static class SensitiveFieldMeta {/*** 默認根據字段名,找db中同名的字段*/private String name;/*** 綁定的數據庫字段別名*/private String bindField;}
}
- 敏感詞替換:
com.github.paicoding.forum.core.senstive.ibatis.SensitiveReadInterceptor#replaceSensitiveResults
,這是具體執行敏感詞替換的方法。
/*** 執行具體的敏感詞替換** @param results* @param mappedStatement* @param sensitiveObjectMeta*/
private void replaceSensitiveResults(Collection<Object> results, MappedStatement mappedStatement, SensitiveObjectMeta sensitiveObjectMeta) {for (Object obj : results) {if (sensitiveObjectMeta.getSensitiveFieldMetaList() == null) {continue;}final MetaObject objMetaObject = mappedStatement.getConfiguration().newMetaObject(obj);sensitiveObjectMeta.getSensitiveFieldMetaList().forEach(i -> {Object value = objMetaObject.getValue(StringUtils.isBlank(i.getBindField()) ? i.getName() : i.getBindField());if (value == null) {return;} else if (value instanceof String) { // 字符串類型,直接進行替換String strValue = (String) value;String processVal = sensitiveService.replace(strValue);objMetaObject.setValue(i.getName(), processVal);} else if (value instanceof Collection) { // 集合類型,需要對集合中的每個元素進行替換Collection listValue = (Collection) value;if (CollectionUtils.isNotEmpty(listValue)) {Optional firstValOpt = listValue.stream().filter(Objects::nonNull).findFirst();if (firstValOpt.isPresent()) {SensitiveObjectMeta valSensitiveObjectMeta = findSensitiveObjectMeta(firstValOpt.get());if (Boolean.TRUE.equals(valSensitiveObjectMeta.getEnabledSensitiveReplace()) && CollectionUtils.isNotEmpty(valSensitiveObjectMeta.getSensitiveFieldMetaList())) {replaceSensitiveResults(listValue, mappedStatement, valSensitiveObjectMeta);}}}} else if (!ClassUtils.isPrimitiveOrWrapper(value.getClass())) {// 對于非基本類型的,需要對其內部進行敏感詞替換SensitiveObjectMeta valSensitiveObjectMeta = findSensitiveObjectMeta(value);if (Boolean.TRUE.equals(valSensitiveObjectMeta.getEnabledSensitiveReplace()) && CollectionUtils.isNotEmpty(valSensitiveObjectMeta.getSensitiveFieldMetaList())) {replaceSensitiveResults(newArrayList(value), mappedStatement, valSensitiveObjectMeta);}}});}
- 敏感詞緩存:
com.github.paicoding.forum.core.senstive.ibatis.SensitiveMetaCache
,為了提高性能,增加了緩存機制,減少每次都對實體對象的成員進行是否需要脫敏的判定。
/*** 敏感詞緩存*/
public class SensitiveMetaCache {private static ConcurrentHashMap<String, SensitiveObjectMeta> CACHE = new ConcurrentHashMap<>();public static SensitiveObjectMeta get(String key) {return CACHE.get(key);}public static void put(String key, SensitiveObjectMeta meta) {CACHE.put(key, meta);}public static void remove(String key) {CACHE.remove(key);}public static boolean contains(String key) {return CACHE.containsKey(key);}public static SensitiveObjectMeta putIfAbsent(String key, SensitiveObjectMeta meta) {return CACHE.putIfAbsent(key, meta);}public static SensitiveObjectMeta computeIfAbsent(String key, Function<String, SensitiveObjectMeta> function) {return CACHE.computeIfAbsent(key, function);}
}
5 實際效果與白名單機制
在實際生產環境中,敏感詞會被替換為 *
號。例如,當數據庫中存儲的敏感詞被檢測到時,會進行相應的替換。同時,我們還實現了敏感詞白名單機制,添加白名單中的詞匯不會被當作敏感詞處理。白名單的動態維護可以在后臺進行全局配置(只有管理員有權限操作),添加白名單后會立即生效,實際效果符合預期。
6 小結
本文詳細介紹了技術派中通用敏感詞替換的相關技術,包括敏感詞校驗的 DFA 算法、敏感詞服務類的使用、基于 Mybatis 攔截器的自定義數據庫脫敏方案等。這些知識點在實際項目中具有很高的實用價值,雖然通常由公司的基礎部門負責支撐,且只有新開項目才有機會親自實踐,但作為合格的研發人員,我們應該多思考如何將一些直接代碼實現的場景抽象為通用的基礎服務能力,同時要時刻牢記安全與合規的重要性,確保自己的系統具備足夠的安全性,防止被惡意攻擊。
7 參考鏈接
- 技術派通用敏感詞替換
- 技術派自定義配置注入&動態刷新
- 項目倉庫(GitHub)
- 項目倉庫(碼云)
- 分支:
origin/feature/sensitive_word