正则表达式用例

Posted on Jul 15, 2022

前言

正则表达式是指定文本中搜索模式的字符序列,这种模式被字符串搜索算法用于对字符串进行查找替换校验等。

拆分

在日志解析领域,时常遇到满足特定模式的日志条目,比如“头部”是时间信息,“身体”是详细信息的日志条目:

% 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/

参考