正则表达式用例
前言
正则表达式是指定文本中搜索模式的字符序列,这种模式被字符串搜索算法用于对字符串进行查找、替换、校验等。
拆分
在日志解析领域,时常遇到满足特定模式的日志条目,比如“头部”是时间信息,“身体”是详细信息的日志条目:
% cat a0.log
<30>May 21 21:33:47 localhost alert: {category=漏洞扫描事件} {type=WebShell} {priority=提示} {typeCN=Web后门} {level=6} {id=974609} {time=2018-05-21 21:33:04} {sip=101.206.169.180} {sport=49187} {dip=10.1.186.7} {dport=9084} {host=m.****.com.cn:9084} {code=200} {sysurl=/sys/web_session.php?level=6&sid=974609} {attach=/sys/web_session.php?act=download&level=6&sid=974609} {intent=上传可执行脚本或WebShell文件} {detail=在Post数据包中发现含有JSP/ASP/ASP.NET代码特征的字符:<%request/QueryString/Form/['...']%>} {dev=zonghang01} {url=http://m.****.com.cn:9084/pmobile/MCPerEAccountSignTrs.do}
人脑能够从少量日志条目归纳出日志的格式,也能从程序开发者的角度看见日志的结构,但是计算机只有在人类的指导(声明字符串满足的模式)下才能区分日志条目的各个组成部分。
% cat a0.log | sd '(<\d+>\w+\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\w+\s+\w+:\s+)(.+)' '$1\n$2'
<30>May 21 21:33:47 localhost alert:
{category=漏洞扫描事件} {type=WebShell} {priority=提示} {typeCN=Web后门} {level=6} {id=974609} {time=2018-05-21 21:33:04} {sip=101.206.169.180} {sport=49187} {dip=10.1.186.7} {dport=9084} {host=m.****.com.cn:9084} {code=200} {sysurl=/sys/web_session.php?level=6&sid=974609} {attach=/sys/web_session.php?act=download&level=6&sid=974609} {intent=上传可执行脚本或WebShell文件} {detail=在Post数据包中发现含有JSP/ASP/ASP.NET代码特征的字符:<%request/QueryString/Form/['...']%>} {dev=zonghang01} {url=http://m.****.com.cn:9084/pmobile/MCPerEAccountSignTrs.do}
命令行工具 sd 用于在文本中查找与替换,它既支持基于字面量的查找,也支持基于正则表达式的查找。上文整条命令表示将文件 a1.log 输入到 sd,它第一个参数是替换前字符串满足的模式(正则表达式),第二个参数是替换后的字符串格式,从输出结果可以看到日志条目的“头部”和“身体”之间已通过行分隔符 \n 分隔,相当于拆分成两个部分。
正则表达式通常包含元字符,其中 (X) 表示一个组(Group)的一名成员 X,一个组是正则表达式匹配到的一个子字符串,通过从 0 开始且步长为 1 的单调递增的索引(index)定位各名成员(子子字符串)。
% echo 'lizzy 2 2002' | sd '(\w+)\s+(\d+)\s+(\d+)' 'name: $1, gender: $2, birth: $3, raw: "$0"'
name: lizzy, gender: 2, birth: 2002, raw: "lizzy 2 2002"
索引为 0 的成员始终代表整个组。
提取键值对
使用正则表达式作用于文本可能匹配到一个或多个组,每个组又能按照索引拆分。
% cat a1.log
<30>May 21 22:01:16 localhost alert: {category=漏洞扫描事件} {type=WebShell} {priority=提示} {typeCN=Web后门} {level=6} {id=974612} {time=2018-05-21 22:01:12} {sip=125.45.235.110} {sport=42425} {dip=10.1.186.5} {dport=9223} {host=ebank.****.com.cn:9223} {code=200} {sysurl=/sys/web_session.php?level=6&sid=974612} {attach=/sys/web_session.php?act=download&level=6&sid=974612} {intent=上传可执行脚本或WebShell文件} {detail=在Post数据包中发现含有JSP/ASP/ASP.NET代码特征的字符:<%request/QueryString/Form/['...']%>} {dev=zonghang01} {url=http://ebank.****.com.cn:9223/pWeb/FP410803.do?Wdserialno=CIP06A029396}
使用 Java APIs 从 a1.log 提取用户感兴趣的信息,例如键值对。
String text = Files.readString(Paths.get("/path/to/a1.log"));
// 移除转义后的表达式为 \{(\w+?)=(.+?)}
Pattern pattern = Pattern.compile("\\{(\\w+?)=(.+?)}");
Matcher matcher = pattern.matcher(text);
while (matcher.find()) {
String kv = matcher.group(0);
String k = matcher.group(1);
String v = matcher.group(2);
System.out.printf("%s <=> %s -> %s" + System.lineSeparator(), kv, k, v);
}
标准输出如下所示:
{category=漏洞扫描事件} <=> category -> 漏洞扫描事件
{type=WebShell} <=> type -> WebShell
{priority=提示} <=> priority -> 提示
{typeCN=Web后门} <=> typeCN -> Web后门
{level=6} <=> level -> 6
{id=974612} <=> id -> 974612
{time=2018-05-21 22:01:12} <=> time -> 2018-05-21 22:01:12
{sip=125.45.235.110} <=> sip -> 125.45.235.110
{sport=42425} <=> sport -> 42425
{dip=10.1.186.5} <=> dip -> 10.1.186.5
{dport=9223} <=> dport -> 9223
{host=ebank.****.com.cn:9223} <=> host -> ebank.****.com.cn:9223
{code=200} <=> code -> 200
{sysurl=/sys/web_session.php?level=6&sid=974612} <=> sysurl -> /sys/web_session.php?level=6&sid=974612
{attach=/sys/web_session.php?act=download&level=6&sid=974612} <=> attach -> /sys/web_session.php?act=download&level=6&sid=974612
{intent=上传可执行脚本或WebShell文件} <=> intent -> 上传可执行脚本或WebShell文件
{detail=在Post数据包中发现含有JSP/ASP/ASP.NET代码特征的字符:<%request/QueryString/Form/['...']%>} <=> detail -> 在Post数据包中发现含有JSP/ASP/ASP.NET代码特征的字符:<%request/QueryString/Form/['...']%>
{dev=zonghang01} <=> dev -> zonghang01
{url=http://ebank.****.com.cn:9223/pWeb/FP410803.do?Wdserialno=CIP06A029396} <=> url -> http://ebank.****.com.cn:9223/pWeb/FP410803.do?Wdserialno=CIP06A029396
即使“头部”有干扰项的日志条目,也不能排除找不到精准的正则表达式去匹配键值对。
% cat b0.log
<134>Mar 17 14:16:11 CMFEACLOG01 BA[5100]:[log_type:flux][record_time:2022-03-17 14:12.24] [user:liujt2] [group:/****/23基金核算部/][host_ip:192.168.79.78][dst_ip:::][serv:访问网站][app:款件下裁][site:未定义位置] [tm_type:/PC/MAC PC][up_flux:120][down_flux:120]
梅开二度:
String text = Files.readString(Paths.get("/path/to/b0.log"));
// 移除转义后的表达式为 \[(\w+?):(.+?)]
Pattern pattern = Pattern.compile("\\[(\\w+?):(.+?)]");
Matcher matcher = pattern.matcher(text);
while (matcher.find()) {
String kv = matcher.group(0);
String k = matcher.group(1);
String v = matcher.group(2);
System.out.printf("%s <=> %s -> %s" + System.lineSeparator(), kv, k, v);
}
符合预期:
[log_type:flux] <=> log_type -> flux
[record_time:2022-03-17 14:12.24] <=> record_time -> 2022-03-17 14:12.24
[user:liujt2] <=> user -> liujt2
[group:/****/23基金核算部/] <=> group -> /****/23基金核算部/
[host_ip:192.168.79.78] <=> host_ip -> 192.168.79.78
[dst_ip:::] <=> dst_ip -> ::
[serv:访问网站] <=> serv -> 访问网站
[app:款件下裁] <=> app -> 款件下裁
[site:未定义位置] <=> site -> 未定义位置
[tm_type:/PC/MAC PC] <=> tm_type -> /PC/MAC PC
[up_flux:120] <=> up_flux -> 120
[down_flux:120] <=> down_flux -> 120
非专业人士也许不懂正则表达式,倒有一种更直观的键值对提取方案,用户至少填写 2 个分隔符:
- 要求切除的前缀,默认为 null。
- 要求切除的后缀,默认为 null。
- 键值对之间的分隔符,不能为 null。
- 键与值之间的分隔符,不能为 null。
- 是否删除值头尾引号,默认为 false。
假设键值对之间的分隔符是 pairSeparator,而键与值之间的分隔符是 kvSeparator,伪代码如下所示:
String[] pairs = text.split(Pattern.quote(pairSeparator));
if (ArrayUtils.isEmpty(pairs)) {
return;
}
Map<String, Object> kv = new HashMap<>(keys.size());
String lastKey = null;
for (String pair : pairs) {
int first = pair.indexOf(kvSeparator);
if (first > 0) {
String key = pair.substring(0, first);
if (StringUtils.isNotBlank(key)) {
String value = pair.substring(key.length() + kvSeparator.length());
kv.put(key, value);
lastKey = key;
}
} else {
// 将孤独的值追加到上一对键值
if (StringUtils.isNotBlank(lastKey)) {
Object lastValue = kv.get(lastKey);
if (Objects.nonNull(lastValue)) {
kv.put(lastKey, lastValue + pairSeparator + pair);
}
}
}
}
方法 split 的坑
说起字符串分隔,方法 split 的参数可是正则表达式!
String str = "a=1} {b=2} {c=3";
String separator = "} {";
// java.util.regex.PatternSyntaxException: Illegal repetition near index 3
// } {
String[] pairs = str.split(separator);
输入的分隔符包含元字符会被当作正则表达式来校验和解析,除了转义,另一种基于字面量的分隔技巧:
String[] pairs = str.split(Pattern.quote(separator));
查找与替换
最近在捣鼓一系列大数据集,遇到了类似于 JSON lines 的文件,理想情况下它们每一行都是一个有效的 JSON 值,遗憾的是它们是二手资料:
% cat ****_sample_750k/person_info.json | jq type
"object"
"object"
parse error: Invalid numeric literal at line 3, column 289
% cat ****_sample_750k/address_merge_with_mobile_data.json | jq type
parse error: Invalid numeric literal at line 1, column 95
% cat ****_sample_750k/case_data_index.json | jq type
parse error: Invalid numeric literal at line 1, column 118
通过 jq 可以快速定位哪些行不是有效的 JSON,视察后使用 sd 等工具将其就地(in-place)替换成合规的 JSON。
#!/bin/bash
file=****_sample_750k/person_info.json
sd -s '"{' '{' $file
sd -s '}"' '}' $file
sd -s '简称"中专"' '简称中专' $file
sd -s '简称"大学"' '简称大学' $file
echo '"]}}}' >> $file
cat $file | jq type
选项 -s
就是 sd 的字符串字面量模式(将表达式视为非正则字符串),但是可能发生“误伤”或“越界”,因为剩余两个类 JSON lines 文件的某些字符串类型的字段的值包含一些 JSON 元字符:
"field":"内容{内容}内容"
(内容可能为空白)
"field":"内容"内容"内容"
混合使用正则模式与非正则模式来修复:
#!/bin/bash
file=****_sample_750k/address_merge_with_mobile_data.json
sd '"\{(.+?)}"' '{$1}' $file
export JAVA_HOME=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home && java StrFieldReplace.java $file
echo '"}}' >> $file
cat $file | jq type
其中脚本 StrFieldReplace.java 的实现如下所示:
public class StrFieldReplace {
static final Pattern[] PATTERNS = new Pattern[]{
// "SRC_ADDRESS":"(.+?)","SRC_ID"
Pattern.compile("\"SRC_ADDRESS\":\"(.+?)\",\"SRC_ID\""),
// "BRIEF_CASE":"(.+?)","CASE_TYPE"
Pattern.compile("\"BRIEF_CASE\":\"(.+?)\",\"CASE_TYPE\""),
// "CASE_ADDRESS":"(.+?)"}
Pattern.compile("\"CASE_ADDRESS\":\"(.+?)\"}"),
// "case_address":"(.+?)","\w+?"
Pattern.compile("\"case_address\":\"(.+?)\",\"\\w+?\""),
// "caseAddress":"(.+?)","\w+?"
Pattern.compile("\"caseAddress\":\"(.+?)\",\"\\w+?\""),
// "CASE_NAME":"(.+?)","CASE_NUMBER"
Pattern.compile("\"CASE_NAME\":\"(.+?)\",\"CASE_NUMBER\""),
};
static final Map<String, String> REGEX_REPL = Map.of(
// " -> '
"\"", "'"
);
public static void main(String[] args) throws IOException {
if (args.length < 1) {
System.err.println("Usage: java StrFieldReplace.java <pathToFile>");
System.exit(1);
}
String pathToFile = args[0];
Path filepath = Paths.get(pathToFile);
List<String> lines = Files.readAllLines(filepath);
if (lines.isEmpty()) {
System.err.println(filepath + " is empty");
return;
}
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
for (Pattern pattern : PATTERNS) {
Matcher matcher = pattern.matcher(line);
while (matcher.find()) {
String value = matcher.group(1);
for (Map.Entry<String, String> entry : REGEX_REPL.entrySet()) {
String regex = entry.getKey();
String repl = entry.getValue();
String newValue = value.replaceAll(regex, repl);
line = line.replace(value, newValue);
lines.set(i, line);
value = newValue;
}
}
}
}
Files.writeString(filepath, String.join(System.lineSeparator(), lines));
}
}
此处,基于字面量替换比基于正则替换更快,在行替换比在全文替换更快。
#!/bin/bash
file=****_sample_750k/case_data_index.json
sd '"ADDR_DETL":"\{(.+?)}","ADDR_TYPE"' '"ADDR_DETL":{$1},"ADDR_TYPE"' $file
export JAVA_HOME=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home && java StrFieldReplace.java $file
sd -s '"{' '{' $file && echo '":null}}}}' >> $file
cat $file | jq type
文本校验
通常始于一行的开头 ^
,终于一行的结尾 $
。
本文首发于 https://h2cone.github.io/